├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── backend-tests.yml │ ├── build-and-push-release-image.yml │ ├── build-and-push-test-image.yml │ ├── build-artifacts.yml │ ├── extension-test.yml │ ├── frontend-test.yml │ └── proto-linter.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── bin └── slash │ └── main.go ├── docker-compose.yml ├── docs ├── assets │ ├── browser-extension-example.png │ ├── demo.png │ ├── extension-usage │ │ ├── extension-setting-button.png │ │ ├── extension-setting-page.png │ │ └── shortcut-url.png │ ├── getting-started │ │ ├── github-sso.png │ │ └── sso-setting.png │ └── wechat.png ├── getting-started │ ├── collections.md │ ├── shortcuts.md │ ├── sso.md │ └── subscription.md ├── install-browser-extension.md ├── install.md └── privacy-policy.md ├── frontend ├── extension │ ├── .eslintrc.json │ ├── .gitignore │ ├── .prettierrc.js │ ├── LICENSE │ ├── README.md │ ├── assets │ │ └── icon.png │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.js │ ├── src │ │ ├── background.ts │ │ ├── components │ │ │ ├── Icon.ts │ │ │ └── Logo.tsx │ │ ├── context │ │ │ ├── context.ts │ │ │ ├── index.ts │ │ │ └── provider.tsx │ │ ├── options.tsx │ │ ├── popup.tsx │ │ └── style.css │ ├── tailwind.config.js │ └── tsconfig.json ├── locales │ ├── README.md │ ├── en.json │ ├── fr.json │ ├── hu.json │ ├── ja.json │ ├── ru.json │ ├── tr.json │ ├── uk.json │ └── zh.json └── web │ ├── .eslintrc.json │ ├── .gitignore │ ├── .prettierrc.js │ ├── README.md │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.js │ ├── public │ └── logo.svg │ ├── src │ ├── App.tsx │ ├── components │ │ ├── AboutDialog.tsx │ │ ├── Alert.tsx │ │ ├── AnalyticsView.tsx │ │ ├── BetaBadge.tsx │ │ ├── ChangePasswordDialog.tsx │ │ ├── CollectionView.tsx │ │ ├── CreateAccessTokenDialog.tsx │ │ ├── CreateCollectionDrawer.tsx │ │ ├── CreateIdentityProviderDrawer.tsx │ │ ├── CreateShortcutDrawer.tsx │ │ ├── CreateUserDialog.tsx │ │ ├── DemoBanner.tsx │ │ ├── EditUserinfoDialog.tsx │ │ ├── FeatureBadge.tsx │ │ ├── FilterView.tsx │ │ ├── GenerateQRCodeDialog.tsx │ │ ├── Header.tsx │ │ ├── Icon.ts │ │ ├── LinkFavicon.tsx │ │ ├── Logo.tsx │ │ ├── Navigator.tsx │ │ ├── PasswordAuthForm.tsx │ │ ├── ResourceNameInput.tsx │ │ ├── ShortcutActionsDropdown.tsx │ │ ├── ShortcutCard.tsx │ │ ├── ShortcutFrame.tsx │ │ ├── ShortcutView.tsx │ │ ├── ShortcutsContainer.tsx │ │ ├── ShortcutsNavigator.tsx │ │ ├── SubscriptionFAQ.tsx │ │ ├── ViewSetting.tsx │ │ ├── VisibilityIcon.tsx │ │ ├── common │ │ │ └── Dropdown.tsx │ │ └── setting │ │ │ ├── AccessTokenSection.tsx │ │ │ ├── AccountSection.tsx │ │ │ ├── PreferenceSection.tsx │ │ │ ├── SSOSection.tsx │ │ │ ├── WorkspaceGeneralSettingSection.tsx │ │ │ ├── WorkspaceMembersSection.tsx │ │ │ └── WorkspaceSecuritySection.tsx │ ├── css │ │ ├── index.css │ │ └── joy-ui.css │ ├── grpcweb.ts │ ├── helpers │ │ └── utils.ts │ ├── hooks │ │ ├── useLoading.ts │ │ ├── useNavigateTo.ts │ │ └── useResponsiveWidth.ts │ ├── i18n.ts │ ├── layouts │ │ ├── CommonContextProvider.tsx │ │ └── Root.tsx │ ├── main.tsx │ ├── pages │ │ ├── AdminSignIn.tsx │ │ ├── AuthCallback.tsx │ │ ├── CollectionDashboard.tsx │ │ ├── CollectionSpace.tsx │ │ ├── Home.tsx │ │ ├── NotFound.tsx │ │ ├── ShortcutDashboard.tsx │ │ ├── ShortcutDetail.tsx │ │ ├── ShortcutSpace.tsx │ │ ├── SignIn.tsx │ │ ├── SignUp.tsx │ │ ├── SubscriptionSetting.tsx │ │ ├── UserSetting.tsx │ │ └── WorkspaceSetting.tsx │ ├── routers │ │ └── index.tsx │ └── stores │ │ ├── collection.ts │ │ ├── index.ts │ │ ├── shortcut.ts │ │ ├── subscription.ts │ │ ├── user.ts │ │ ├── view.ts │ │ └── workspace.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── go.mod ├── go.sum ├── internal ├── cron │ ├── cron.go │ ├── cron_test.go │ ├── schedule.go │ └── schedule_test.go └── util │ ├── util.go │ └── util_test.go ├── plugin ├── httpgetter │ ├── html_meta.go │ ├── html_meta_test.go │ ├── http_getter.go │ ├── image.go │ └── util.go ├── idp │ ├── idp.go │ └── oauth2 │ │ ├── oauth2.go │ │ └── oauth2_test.go └── mail │ ├── login_auth.go │ └── mail.go ├── proto ├── api │ └── v1 │ │ ├── auth_service.proto │ │ ├── collection_service.proto │ │ ├── common.proto │ │ ├── shortcut_service.proto │ │ ├── subscription_service.proto │ │ ├── user_service.proto │ │ ├── user_setting_service.proto │ │ └── workspace_service.proto ├── buf.gen.yaml ├── buf.lock ├── buf.yaml ├── gen │ ├── api │ │ └── v1 │ │ │ ├── README.md │ │ │ ├── auth_service.pb.go │ │ │ ├── auth_service.pb.gw.go │ │ │ ├── auth_service_grpc.pb.go │ │ │ ├── collection_service.pb.go │ │ │ ├── collection_service.pb.gw.go │ │ │ ├── collection_service_grpc.pb.go │ │ │ ├── common.pb.go │ │ │ ├── shortcut_service.pb.go │ │ │ ├── shortcut_service.pb.gw.go │ │ │ ├── shortcut_service_grpc.pb.go │ │ │ ├── subscription_service.pb.go │ │ │ ├── subscription_service.pb.gw.go │ │ │ ├── subscription_service_grpc.pb.go │ │ │ ├── user_service.pb.go │ │ │ ├── user_service.pb.gw.go │ │ │ ├── user_service_grpc.pb.go │ │ │ ├── user_setting_service.pb.go │ │ │ ├── user_setting_service.pb.gw.go │ │ │ ├── user_setting_service_grpc.pb.go │ │ │ ├── workspace_service.pb.go │ │ │ ├── workspace_service.pb.gw.go │ │ │ └── workspace_service_grpc.pb.go │ ├── apidocs.swagger.md │ ├── apidocs.swagger.yaml │ └── store │ │ ├── README.md │ │ ├── activity.pb.go │ │ ├── collection.pb.go │ │ ├── common.pb.go │ │ ├── idp.pb.go │ │ ├── shortcut.pb.go │ │ ├── user_setting.pb.go │ │ └── workspace_setting.pb.go └── store │ ├── activity.proto │ ├── collection.proto │ ├── common.proto │ ├── idp.proto │ ├── shortcut.proto │ ├── user_setting.proto │ └── workspace_setting.proto ├── scripts └── build.sh ├── server ├── common │ ├── common.go │ ├── version.go │ └── version_test.go ├── profile │ └── profile.go ├── route │ ├── api │ │ └── v1 │ │ │ ├── acl.go │ │ │ ├── acl_config.go │ │ │ ├── auth.go │ │ │ ├── auth_service.go │ │ │ ├── collection_service.go │ │ │ ├── common.go │ │ │ ├── logger_interceptor.go │ │ │ ├── resource_name.go │ │ │ ├── shortcut_service.go │ │ │ ├── subscription_service.go │ │ │ ├── user_service.go │ │ │ ├── user_setting_service.go │ │ │ ├── v1.go │ │ │ └── workspace_service.go │ └── frontend │ │ ├── dist │ │ └── index.html │ │ └── frontend.go ├── runner │ ├── license │ │ └── runner.go │ └── version │ │ └── runner.go ├── server.go └── service │ └── license │ ├── feature_matrix.go │ ├── lemonsqueezy │ ├── lemonsqueezy.go │ ├── requests.go │ └── requests_test.go │ ├── license.go │ └── slash.public.pem └── store ├── activity.go ├── cache.go ├── collection.go ├── common.go ├── db ├── db.go ├── postgres │ ├── activity.go │ ├── collection.go │ ├── common.go │ ├── migration_history.go │ ├── postgres.go │ ├── shortcut.go │ ├── user.go │ ├── user_setting.go │ └── workspace_setting.go └── sqlite │ ├── activity.go │ ├── collection.go │ ├── common.go │ ├── migration_history.go │ ├── shortcut.go │ ├── sqlite.go │ ├── user.go │ ├── user_setting.go │ └── workspace_setting.go ├── driver.go ├── migration ├── postgres │ ├── dev │ │ └── LATEST.sql │ └── prod │ │ ├── 1.0 │ │ └── 00__visibility.sql │ │ └── LATEST.sql └── sqlite │ ├── dev │ └── LATEST.sql │ └── prod │ ├── 0.2 │ └── 00__create_index.sql │ ├── 0.3 │ └── 00__add_og_metadata.sql │ ├── 0.4 │ └── 00__add_shortcut_title.sql │ ├── 0.5 │ ├── 00__drop_idp.sql │ └── 01__collection.sql │ ├── 1.0 │ └── 00__visibility.sql │ └── LATEST.sql ├── migration_history.go ├── migrator.go ├── seed └── sqlite │ ├── 10000__user.sql │ ├── 10001__shortcut.sql │ └── 10002__collection.sql ├── shortcut.go ├── store.go ├── test ├── activity_test.go ├── collection_test.go ├── migrator_test.go ├── shortcut_test.go ├── store.go ├── user_setting_test.go ├── user_test.go └── workspace_setting_test.go ├── user.go ├── user_setting.go └── workspace_setting.go /.dockerignore: -------------------------------------------------------------------------------- 1 | */*/node_modules 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | If you are reporting a new issue, make sure that we do not have any duplicates already open. You can ensure this by searching the issue list for this repository. If there is a duplicate, please close your issue and add a comment to the existing issue instead. 9 | - type: textarea 10 | attributes: 11 | label: Describe the bug 12 | description: | 13 | Briefly describe the problem you are having in a few paragraphs. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Steps to reproduce 19 | description: | 20 | Provide the steps to reproduce the issue. 21 | placeholder: | 22 | 1. Go to '...' 23 | 3. Click on '....' 24 | 4. See error 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: Expected behavior 30 | description: | 31 | Describe what you expected to happen. 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Screenshots or additional context 37 | description: | 38 | Add screenshots or any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to suggest an idea for Slash! 9 | - type: textarea 10 | attributes: 11 | label: Is your feature request related to a problem? 12 | description: | 13 | A clear and concise description of what the problem is. 14 | placeholder: | 15 | I'm always frustrated when [...] 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Describe the solution you'd like 21 | description: | 22 | A clear and concise description of what you want to happen. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional context 28 | description: Add any other context or screenshots about the feature request here. 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | commit-message: 5 | prefix: "chore" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | - package-ecosystem: npm 10 | commit-message: 11 | prefix: "chore" 12 | directory: "/frontend/web" 13 | schedule: 14 | interval: "monthly" 15 | - package-ecosystem: npm 16 | commit-message: 17 | prefix: "chore" 18 | directory: "/frontend/extension" 19 | schedule: 20 | interval: "monthly" 21 | - package-ecosystem: "gomod" 22 | commit-message: 23 | prefix: "chore" 24 | directory: "/" 25 | schedule: 26 | interval: "monthly" 27 | -------------------------------------------------------------------------------- /.github/workflows/backend-tests.yml: -------------------------------------------------------------------------------- 1 | name: Backend Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release/v*.*.*" 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | go-static-checks: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: 1.23 19 | check-latest: true 20 | cache: true 21 | - name: Verify go.mod is tidy 22 | run: | 23 | go mod tidy 24 | git diff --exit-code 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v7 27 | with: 28 | version: v2.0.2 29 | args: --verbose --timeout=3m 30 | skip-cache: true 31 | 32 | go-tests: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: actions/setup-go@v5 37 | with: 38 | go-version: 1.23 39 | check-latest: true 40 | cache: true 41 | - name: Run all tests 42 | run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]} 43 | - name: Pretty print tests running time 44 | run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}' 45 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push-release-image.yml: -------------------------------------------------------------------------------- 1 | name: build-and-push-stable-image 2 | 3 | on: 4 | push: 5 | tags: 6 | # Match stable and rc versions, such as 'v1.0.0' or 'v0.23.0-rc.0' 7 | - "v*.*.*" 8 | - "v*.*.*-rc.*" 9 | 10 | jobs: 11 | build-and-push-stable-image: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v3 21 | 22 | - name: Extract build args 23 | # Extract version number and check if it's an rc version 24 | run: | 25 | if [[ "${GITHUB_REF_NAME}" =~ -rc ]]; then 26 | echo "PRE_RELEASE=true" >> $GITHUB_ENV 27 | else 28 | echo "PRE_RELEASE=false" >> $GITHUB_ENV 29 | fi 30 | echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV 31 | 32 | - name: Login to Docker Hub 33 | uses: docker/login-action@v3 34 | with: 35 | username: yourselfhosted 36 | password: ${{ secrets.DOCKER_TOKEN }} 37 | 38 | - name: Set up Docker Buildx 39 | id: buildx 40 | uses: docker/setup-buildx-action@v3 41 | with: 42 | install: true 43 | version: v0.9.1 44 | 45 | # Metadata for stable versions 46 | - name: Docker meta for stable 47 | id: meta-stable 48 | if: env.PRE_RELEASE == 'false' 49 | uses: docker/metadata-action@v5 50 | with: 51 | images: | 52 | yourselfhosted/slash 53 | tags: | 54 | type=semver,pattern={{version}},value=${{ env.VERSION }} 55 | type=raw,value=stable 56 | flavor: | 57 | latest=true 58 | labels: | 59 | org.opencontainers.image.version=${{ env.VERSION }} 60 | 61 | # Metadata for rc versions 62 | - name: Docker meta for rc 63 | id: meta-rc 64 | if: env.PRE_RELEASE == 'true' 65 | uses: docker/metadata-action@v5 66 | with: 67 | images: | 68 | yourselfhosted/slash 69 | tags: | 70 | type=raw,value=${{ env.VERSION }} 71 | labels: | 72 | org.opencontainers.image.version=${{ env.VERSION }} 73 | 74 | - name: Build and Push 75 | id: docker_build 76 | uses: docker/build-push-action@v6 77 | with: 78 | context: ./ 79 | file: ./Dockerfile 80 | platforms: linux/amd64,linux/arm64 81 | push: true 82 | tags: ${{ steps.meta-stable.outputs.tags || steps.meta-rc.outputs.tags }} 83 | labels: ${{ steps.meta-stable.outputs.labels || steps.meta-rc.outputs.labels }} 84 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push-test-image.yml: -------------------------------------------------------------------------------- 1 | name: build-and-push-test-image 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build-and-push-test-image: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v3 15 | 16 | - name: Login to Docker Hub 17 | uses: docker/login-action@v3 18 | with: 19 | username: yourselfhosted 20 | password: ${{ secrets.DOCKER_TOKEN }} 21 | 22 | - name: Set up Docker Buildx 23 | id: buildx 24 | uses: docker/setup-buildx-action@v3 25 | with: 26 | install: true 27 | version: v0.9.1 28 | 29 | - name: Build and Push 30 | id: docker_build 31 | uses: docker/build-push-action@v6 32 | with: 33 | context: ./ 34 | file: ./Dockerfile 35 | platforms: linux/amd64 36 | push: true 37 | tags: yourselfhosted/slash:test 38 | -------------------------------------------------------------------------------- /.github/workflows/build-artifacts.yml: -------------------------------------------------------------------------------- 1 | name: Build artifacts 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: 1.23 22 | check-latest: true 23 | cache: true 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | # either 'goreleaser' (default) or 'goreleaser-pro' 28 | distribution: goreleaser 29 | # 'latest', 'nightly', or a semver 30 | version: latest 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/extension-test.yml: -------------------------------------------------------------------------------- 1 | name: Extension Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release/v*.*.*" 8 | pull_request: 9 | branches: [main] 10 | paths: 11 | - "frontend/extension/**" 12 | 13 | jobs: 14 | eslint-checks: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4.1.0 19 | with: 20 | version: 10 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: "22" 24 | cache: pnpm 25 | cache-dependency-path: "frontend/extension/pnpm-lock.yaml" 26 | - run: pnpm install 27 | working-directory: frontend/extension 28 | - name: Run eslint check 29 | run: pnpm lint 30 | working-directory: frontend/extension 31 | 32 | extension-build: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: pnpm/action-setup@v4.1.0 37 | with: 38 | version: 10 39 | - uses: actions/setup-node@v4 40 | with: 41 | node-version: "22" 42 | cache: pnpm 43 | cache-dependency-path: "frontend/extension/pnpm-lock.yaml" 44 | - run: pnpm install 45 | working-directory: frontend/extension 46 | - name: Run extension build 47 | run: pnpm build 48 | working-directory: frontend/extension 49 | -------------------------------------------------------------------------------- /.github/workflows/frontend-test.yml: -------------------------------------------------------------------------------- 1 | name: Frontend Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release/v*.*.*" 8 | pull_request: 9 | branches: [main] 10 | paths: 11 | - "frontend/web/**" 12 | 13 | jobs: 14 | eslint-checks: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4.1.0 19 | with: 20 | version: 10 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: "22" 24 | cache: pnpm 25 | cache-dependency-path: "frontend/web/pnpm-lock.yaml" 26 | - run: pnpm install 27 | working-directory: frontend/web 28 | - name: Run eslint check 29 | run: pnpm lint 30 | working-directory: frontend/web 31 | - name: Run type check 32 | run: pnpm type-check 33 | working-directory: frontend/web 34 | 35 | frontend-build: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: pnpm/action-setup@v4.1.0 40 | with: 41 | version: 10 42 | - uses: actions/setup-node@v4 43 | with: 44 | node-version: "22" 45 | cache: pnpm 46 | cache-dependency-path: "frontend/web/pnpm-lock.yaml" 47 | - run: pnpm install 48 | working-directory: frontend/web 49 | - name: Run frontend build 50 | run: pnpm build 51 | working-directory: frontend/web 52 | -------------------------------------------------------------------------------- /.github/workflows/proto-linter.yml: -------------------------------------------------------------------------------- 1 | name: Proto linter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release/v*.*.*" 8 | pull_request: 9 | branches: 10 | - main 11 | - "release/*.*.*" 12 | paths: 13 | - "proto/**" 14 | 15 | jobs: 16 | lint-protos: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: Setup buf 24 | uses: bufbuild/buf-setup-action@v1 25 | - name: buf lint 26 | uses: bufbuild/buf-lint-action@v1 27 | with: 28 | input: "proto" 29 | - name: buf format 30 | run: | 31 | if [[ $(buf format -d) ]]; then 32 | echo "Run 'buf format -w'" 33 | exit 1 34 | fi 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # temp folder 2 | tmp 3 | 4 | # build folder 5 | build 6 | 7 | .DS_Store 8 | 9 | node_modules 10 | 11 | .env 12 | 13 | dist/ 14 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | main: ./bin/slash 12 | binary: slash 13 | goos: 14 | - linux 15 | - darwin 16 | 17 | archives: 18 | - format: tar.gz 19 | # this name template makes the OS and Arch compatible with the results of `uname`. 20 | name_template: >- 21 | {{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }} 22 | 23 | changelog: 24 | sort: asc 25 | filters: 26 | exclude: 27 | - "^docs:" 28 | - "^test:" 29 | 30 | checksum: 31 | disable: true 32 | 33 | release: 34 | draft: true 35 | replace_existing_draft: true 36 | make_latest: true 37 | mode: replace 38 | skip_upload: false 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build frontend dist. 2 | FROM node:18-alpine AS frontend 3 | WORKDIR /frontend-build 4 | 5 | COPY . . 6 | 7 | WORKDIR /frontend-build/frontend/web 8 | 9 | RUN corepack enable && pnpm i --frozen-lockfile 10 | 11 | RUN pnpm build 12 | 13 | # Build backend exec file. 14 | FROM golang:1.23-alpine AS backend 15 | WORKDIR /backend-build 16 | 17 | COPY . . 18 | COPY --from=frontend /frontend-build/frontend/web/dist /backend-build/server/route/frontend/dist 19 | 20 | RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go 21 | 22 | # Make workspace with above generated files. 23 | FROM alpine:latest AS monolithic 24 | WORKDIR /usr/local/slash 25 | 26 | RUN apk add --no-cache tzdata 27 | ENV TZ="UTC" 28 | 29 | COPY --from=backend /backend-build/slash /usr/local/slash/ 30 | 31 | EXPOSE 5231 32 | 33 | # Directory to store the data, which can be referenced as the mounting point. 34 | RUN mkdir -p /var/opt/slash 35 | VOLUME /var/opt/slash 36 | 37 | ENV SLASH_MODE="prod" 38 | ENV SLASH_PORT="5231" 39 | 40 | ENTRYPOINT ["./slash"] 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | slash: 4 | image: yourselfhosted/slash:latest 5 | container_name: slash 6 | ports: 7 | - 5231:5231 8 | volumes: 9 | - slash:/var/opt/slash 10 | restart: unless-stopped 11 | 12 | volumes: 13 | slash: -------------------------------------------------------------------------------- /docs/assets/browser-extension-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/browser-extension-example.png -------------------------------------------------------------------------------- /docs/assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/demo.png -------------------------------------------------------------------------------- /docs/assets/extension-usage/extension-setting-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/extension-usage/extension-setting-button.png -------------------------------------------------------------------------------- /docs/assets/extension-usage/extension-setting-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/extension-usage/extension-setting-page.png -------------------------------------------------------------------------------- /docs/assets/extension-usage/shortcut-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/extension-usage/shortcut-url.png -------------------------------------------------------------------------------- /docs/assets/getting-started/github-sso.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/getting-started/github-sso.png -------------------------------------------------------------------------------- /docs/assets/getting-started/sso-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/getting-started/sso-setting.png -------------------------------------------------------------------------------- /docs/assets/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/docs/assets/wechat.png -------------------------------------------------------------------------------- /docs/getting-started/collections.md: -------------------------------------------------------------------------------- 1 | # Slash Collections 2 | 3 | **Slash Collections** introduces a feature to help you better organize and manage related Shortcuts. 4 | 5 | ## What is a Collection? 6 | 7 | A Collection is like a virtual folder where you can group and organize your related Shortcuts. It acts as a container that holds Shortcuts together for a specific purpose or theme. Let's break down the key attributes: 8 | 9 | - **Name:** Your chosen label for the Collection. This becomes a crucial part of the URL, enabling direct and quick access to the Collection. For example, if your Collection is named "work-projects", the direct access link would be `c/work-projects`. This user-defined name significantly enhances the accessibility and recognition of your Collections. 10 | - **Title:** A brief title summarizing the Collection's content. 11 | - **Description:** A short description explaining what the Collection is about. 12 | - **Shortcuts:** The Shortcuts included in the Collection. 13 | - **Visibility:** Settings to control who can access the Collection. 14 | 15 | ## What Problems Does It Solve? 16 | 17 | Slash Collections tackle the challenge of efficiently managing and organizing related Shortcuts. By grouping Shortcuts into Collections, you can create a more structured and accessible workflow. This makes it easier to find, access, and share information based on specific themes or projects. 18 | 19 | ## How to Use Collections 20 | 21 | ### Creating a Collection 22 | 23 | 1. **Define the Collection:** Give your Collection a meaningful name and a descriptive title. 24 | 2. **Add Details:** Provide a brief description of the content within the Collection. 25 | 3. **Add Shortcuts:** Include relevant Shortcuts by selecting them from your existing list. 26 | 4. **Set Visibility:** Choose who should have access to the Collection. 27 | 5. **Save:** Once saved, your Collection is ready to use. 28 | 29 | ### Accessing Collections 30 | 31 | Access a Collection directly by using the assigned name. For example, if your Collection is named "work-projects", the direct access link would be `{YOUR_DOMAIN}/c/work-projects`. 32 | 33 | ### Updating and Managing Collections 34 | 35 | Modify Collection details, such as name, title, or included Shortcuts, to keep your organization streamlined and relevant. 36 | 37 | ### Sharing Collections 38 | 39 | Share Collections by providing the assigned name to collaborators for easy access to grouped Shortcuts. 40 | 41 | ## Conclusion 42 | 43 | Slash Collections offer a user-friendly and organized way to group, manage, and share related Shortcuts. By utilizing the defined Collection attributes, users can seamlessly categorize and access information, promoting collaboration and improving overall productivity. 44 | -------------------------------------------------------------------------------- /docs/getting-started/shortcuts.md: -------------------------------------------------------------------------------- 1 | # Slash Shortcuts 2 | 3 | **Slash Shortcuts** is a handy tool designed to make handling and sharing links in your digital workspace a breeze. 4 | 5 | ## What is a Shortcut? 6 | 7 | A Shortcut is a simplified version of a link with essential details, making it easy to remember, organize, and share. Let's break down the key elements: 8 | 9 | - **Name:** Your chosen label for the Shortcut. This becomes a crucial part of the URL, enabling direct and quick access to the Shortcut. For example, if your Shortcut is named "meet-john", the direct access link would be `s/meet-john`. This user-defined name significantly enhances the accessibility and recognition of your Shortcuts. 10 | - **Link:** The original web link you want to streamline. 11 | - **Title:** A quick overview of what's behind the link. 12 | - **Tags:** Custom labels for easy sorting. 13 | - **Description:** A short summary of the content. 14 | - **Visibility:** Controls who can access the Shortcut. 15 | 16 | ## How to Use Shortcuts 17 | 18 | ### Creating a Shortcut 19 | 20 | 1. **Define the Link:** Paste the original link you want to simplify. 21 | 2. **Add Details:** Give it a name, tags, and a brief description for better organization. 22 | 3. **Set Visibility:** Choose who should be able to access the Shortcut. 23 | 4. **Save:** Once saved, your Shortcut is ready to go. 24 | 25 | ### Accessing Shortcuts 26 | 27 | #### Direct Access 28 | 29 | Effortlessly access your Shortcut's content directly by using the assigned name as part of the Slash Shortcuts format. 30 | 31 | For example, if your Shortcut is named "meet-john", the direct access link would be `{YOUR_DOMAIN}/s/meet-john`. Simply enter this user-friendly shortcut into your browser to reach the associated content with ease. 32 | 33 | #### Browser Extension Access 34 | 35 | Install the Slash Shortcuts browser extension for even quicker access. Once installed, simply type `s/meet-john` into your browser's address bar, and the extension will seamlessly redirect you to the corresponding page. 36 | 37 | ### Updating and Managing Shortcuts 38 | 39 | Adjust attributes like name and tags to update a Shortcut. Keep your Shortcuts organized based on categories and visibility settings. 40 | 41 | ### Sharing Shortcuts 42 | 43 | Share Shortcuts by providing the assigned name to collaborators for easy access. 44 | 45 | ## Conclusion 46 | 47 | Shortcuts provide a simple way to manage, organize, and share links within your digital workspace. By using the defined Shortcut attributes, users can easily create, access, and share information, promoting collaboration and boosting productivity. 48 | -------------------------------------------------------------------------------- /docs/getting-started/sso.md: -------------------------------------------------------------------------------- 1 | # Single Sign-On(SSO) 2 | 3 | > **Note**: This feature is only available in the **Team** plan. 4 | 5 | **Single Sign-On (SSO)** is an authentication method that enables users to securely authenticate with multiple applications and websites by using just one set of credentials. 6 | 7 | Slash supports SSO integration with **OAuth 2.0** standard. 8 | 9 | ## Create a new SSO provider 10 | 11 | As an Admin user, you can create a new SSO provider in Setting > Workspace settings > SSO. 12 | 13 | ![sso-setting](../assets/getting-started/sso-setting.png) 14 | 15 | For example, to integrate with GitHub, you might need to fill in the following fields: 16 | 17 | ![github-sso](../assets/getting-started/github-sso.png) 18 | 19 | ### Identity provider information 20 | 21 | The information is the base concept of OAuth 2.0 and comes from your provider. 22 | 23 | - **Client ID** is a public identifier of the custom provider; 24 | - **Client Secret** is the OAuth2 client secret from identity provider; 25 | - **Authorization endpoint** is the custom provider's OAuth2 login page address; 26 | - **Token endpoint** is the API address for obtaining access token; 27 | - **User endpoint** URL is the API address for obtaining user information by access token; 28 | - **Scopes** is the scope parameter carried when accessing the OAuth2 URL, which is filled in according to the custom provider; 29 | 30 | ### User information mapping 31 | 32 | For different providers, the structures returned by their user information API are usually not the same. In order to know how to map the user information from an provider into user fields, you need to fill the user information mapping form. 33 | 34 | Slash will use the mapping to import the user profile fields when creating new accounts. The most important user field mapping is the identifier which is used to identify the Slash account associated with the OAuth 2.0 login. 35 | 36 | - **Identifier** is the field name of primary email in 3rd-party user info; 37 | - **Display name** is the field name of display name in 3rd-party user info (optional); 38 | -------------------------------------------------------------------------------- /docs/getting-started/subscription.md: -------------------------------------------------------------------------------- 1 | # Subscription 2 | 3 | Slash is an open source, self-hosted platform for sharing and managing your most frequently used links. Easily create customizable, human-readable shortcuts to streamline your link management. Our source code is available and accessible on GitHub so anyone can get it, inspect it and review it. 4 | 5 | ## Plans 6 | 7 | ### Free 8 | 9 | The Free plan is designed for personal use not for commercial use. It allows you to create up to 100 shortcuts and invite up to 5 members. 10 | 11 | ### Pro 12 | 13 | The Pro plan is designed for teams and businesses. It allows you to create unlimited shortcuts and invite unlimited members. It also includes priority support. The Pro plan is $4 per month. 14 | 15 | ### Team 16 | 17 | The Team plan is designed for teams that need more than the Pro plan. It allows you to use Single Sign-On(SSO) and other advanced features. If you need a team plan, please contact us at `yourselfhosted@gmail.com`. 18 | 19 | ## Using a License Key 20 | 21 | After purchasing a Pro or Team plan, you will receive a license key. You can use the license key to activate your plan. Here is how to do it: 22 | 23 | 1. Log in to your Slash instance as an Admin user. 24 | 2. Go to Settings > Subscription. `https://your-slash-instance.com/setting/subscription` 25 | 3. You will see a form to enter your license key. Enter your license key and click the **Upload license** button. 26 | 4. If the license key is valid, your plan will be activated. 27 | 28 | ## FAQ 29 | 30 | ### Can I use the Free plan in my team? 31 | 32 | Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you should upgrade to the Pro plan. 33 | 34 | ### How many devices can the license key be used on? 35 | 36 | It's unlimited for now, but please do not abuse it. 37 | 38 | ### Can I get a refund if Slash doesn't meet my needs? 39 | 40 | Yes, absolutely! You can contact us with `yourselfhosted@gmail.com`. I will refund you as soon as possible. 41 | 42 | ### Is there a Lifetime license? 43 | 44 | As software requires someone to maintain it, so we won't sell a lifetime service, since humans are not immortal yet. But if you really want it, please contact us `yourselfhosted@gmail.com`. 45 | -------------------------------------------------------------------------------- /docs/install-browser-extension.md: -------------------------------------------------------------------------------- 1 | # The Browser Extension of Slash 2 | 3 | Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL. 4 | 5 | ## How to use 6 | 7 | ### Install the extension 8 | 9 | For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg). 10 | 11 | For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/). 12 | 13 | ### Prerequisites 14 | 15 | - You need to have a Slash instance running. 16 | - Sign in with your account on the Slash instance. 17 | 18 | ### Configure the extension 19 | 20 | The extension needs to know the instance url of your Slash. You can configure it by following the steps below: 21 | 22 | 1. Click on the extension icon and click on the "Settings" button. 23 | 24 | ![](./assets/extension-usage/extension-setting-button.png) 25 | 26 | 2. Enter the instance url of your Slash and then "Save". 27 | 28 | ![](./assets/extension-usage/extension-setting-page.png) 29 | 30 | ### Use your shortcuts in the search bar 31 | 32 | You can use your shortcuts in the search bar of your browser. For example, if you have a shortcut named `gh` for [GitHub](https://github.com), you can type `s/gh` in the search bar and press `Enter` to go to [GitHub](https://github.com). 33 | 34 | ![](./assets/extension-usage/shortcut-url.png) 35 | -------------------------------------------------------------------------------- /docs/privacy-policy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | Slash does not collect, store, or share any data from you. 4 | 5 | You can use our application freely and without concern about your personal data being tracked or stored. 6 | 7 | Our primary goal is to provide you with a secure and private experience. 8 | -------------------------------------------------------------------------------- /frontend/extension/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "jsx": true 11 | }, 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": ["react", "@typescript-eslint", "prettier"], 16 | "ignorePatterns": ["node_modules", "dist", "public"], 17 | "rules": { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | "endOfLine": "auto" 22 | } 23 | ], 24 | "@typescript-eslint/no-explicit-any": ["off"], 25 | "react/react-in-jsx-scope": "off", 26 | "react/jsx-no-target-blank": "off" 27 | }, 28 | "settings": { 29 | "react": { 30 | "version": "detect" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/extension/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | #cache 13 | .turbo 14 | 15 | # misc 16 | .DS_Store 17 | *.pem 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .pnpm-debug.log* 24 | 25 | # local env files 26 | .env* 27 | 28 | out/ 29 | build/ 30 | dist/ 31 | 32 | .plasmo 33 | 34 | # bpp - http://bpp.browser.market/ 35 | keys.json 36 | 37 | # typescript 38 | .tsbuildinfo 39 | 40 | src/types/proto 41 | -------------------------------------------------------------------------------- /frontend/extension/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 140, 3 | useTabs: false, 4 | semi: true, 5 | singleQuote: false, 6 | plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")], 7 | importOrder: ["", "", "^@/((?!css).+)", "^[./]", "^[../]", "^(.+).css"], 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/extension/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 yourselfhosted 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/extension/README.md: -------------------------------------------------------------------------------- 1 | # Slash Browser Extension 2 | -------------------------------------------------------------------------------- /frontend/extension/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourselfhosted/slash/c542b43e57424eef5971c99b42e2c265f3da9ca4/frontend/extension/assets/icon.png -------------------------------------------------------------------------------- /frontend/extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slash-extension", 3 | "displayName": "Slash", 4 | "version": "1.0.12", 5 | "description": "An open source, self-hosted platform for sharing and managing your most frequently used links.", 6 | "scripts": { 7 | "dev": "plasmo dev", 8 | "build": "export PARCEL_WORKER_BACKEND=process && plasmo build", 9 | "package": "plasmo package", 10 | "lint": "eslint --ext .js,.ts,.tsx, src", 11 | "lint-fix": "eslint --ext .js,.ts,.tsx, src --fix" 12 | }, 13 | "dependencies": { 14 | "@emotion/react": "^11.14.0", 15 | "@emotion/styled": "^11.14.0", 16 | "@mui/joy": "5.0.0-beta.51", 17 | "@plasmohq/storage": "^1.15.0", 18 | "classnames": "^2.5.1", 19 | "lucide-react": "^0.486.0", 20 | "plasmo": "^0.90.3", 21 | "react": "^18.3.1", 22 | "react-dom": "^18.3.1", 23 | "react-hot-toast": "^2.5.2" 24 | }, 25 | "devDependencies": { 26 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 27 | "@types/chrome": "^0.0.287", 28 | "@types/node": "^22.15.17", 29 | "@types/react": "^18.3.21", 30 | "@types/react-dom": "^18.3.7", 31 | "@typescript-eslint/eslint-plugin": "^7.18.0", 32 | "@typescript-eslint/parser": "^7.18.0", 33 | "autoprefixer": "^10.4.21", 34 | "eslint": "^8.57.1", 35 | "eslint-config-prettier": "^9.1.0", 36 | "eslint-plugin-prettier": "^5.4.0", 37 | "eslint-plugin-react": "^7.37.5", 38 | "postcss": "^8.5.3", 39 | "prettier": "^3.5.3", 40 | "tailwindcss": "^3.4.17", 41 | "typescript": "^5.8.3" 42 | }, 43 | "manifest": { 44 | "permissions": [ 45 | "activeTab", 46 | "storage", 47 | "webRequest" 48 | ], 49 | "host_permissions": [ 50 | "*://*/*" 51 | ] 52 | }, 53 | "pnpm": { 54 | "overrides": { 55 | "@swc/core": "1.5.7" 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /frontend/extension/postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /** 3 | * @type {import('postcss').ProcessOptions} 4 | */ 5 | module.exports = { 6 | plugins: { 7 | tailwindcss: {}, 8 | autoprefixer: {}, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/extension/src/background.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "@plasmohq/storage"; 2 | 3 | const storage = new Storage(); 4 | const urlRegex = /https?:\/\/s\/(.+)/; 5 | 6 | chrome.webRequest.onBeforeRequest.addListener( 7 | (param) => { 8 | (async () => { 9 | if (!param.url) { 10 | return; 11 | } 12 | 13 | const shortcutName = getShortcutNameFromUrl(param.url); 14 | if (shortcutName) { 15 | const instanceUrl = (await storage.getItem("instance_url")) || ""; 16 | const url = new URL(`/s/${shortcutName}`, instanceUrl); 17 | return chrome.tabs.update({ url: url.toString() }); 18 | } 19 | })(); 20 | }, 21 | { urls: ["*://s/*", "*://*/search*", "*://*/s*", "*://duckduckgo.com/*"] }, 22 | ); 23 | 24 | const getShortcutNameFromUrl = (urlString: string) => { 25 | const matchResult = urlRegex.exec(urlString); 26 | if (matchResult === null) { 27 | return getShortcutNameFromSearchUrl(urlString); 28 | } 29 | return matchResult[1]; 30 | }; 31 | 32 | const getShortcutNameFromSearchUrl = (urlString: string) => { 33 | const url = new URL(urlString); 34 | if ((url.hostname.endsWith("google.com") || url.hostname.endsWith("bing.com")) && url.pathname === "/search") { 35 | const params = new URLSearchParams(url.search); 36 | const shortcutName = params.get("q"); 37 | if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) { 38 | return shortcutName.slice(2); 39 | } 40 | } else if (url.hostname.endsWith("baidu.com") && url.pathname === "/s") { 41 | const params = new URLSearchParams(url.search); 42 | const shortcutName = params.get("wd"); 43 | if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) { 44 | return shortcutName.slice(2); 45 | } 46 | } else if (url.hostname.endsWith("duckduckgo.com") && url.pathname === "/") { 47 | const params = new URLSearchParams(url.search); 48 | const shortcutName = params.get("q"); 49 | if (typeof shortcutName === "string" && shortcutName.startsWith("s/")) { 50 | return shortcutName.slice(2); 51 | } 52 | } 53 | return ""; 54 | }; 55 | -------------------------------------------------------------------------------- /frontend/extension/src/components/Icon.ts: -------------------------------------------------------------------------------- 1 | import * as Icon from "lucide-react"; 2 | 3 | export default Icon; 4 | -------------------------------------------------------------------------------- /frontend/extension/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import Icon from "./Icon"; 3 | 4 | interface Props { 5 | className?: string; 6 | } 7 | 8 | const Logo = ({ className }: Props) => { 9 | return ; 10 | }; 11 | 12 | export default Logo; 13 | -------------------------------------------------------------------------------- /frontend/extension/src/context/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | interface Context { 4 | instanceUrl?: string; 5 | setInstanceUrl: (instanceUrl: string) => void; 6 | } 7 | 8 | export const StorageContext = createContext({ 9 | instanceUrl: undefined, 10 | setInstanceUrl: () => {}, 11 | }); 12 | 13 | const useStorageContext = () => { 14 | const context = useContext(StorageContext); 15 | return context; 16 | }; 17 | 18 | export default useStorageContext; 19 | -------------------------------------------------------------------------------- /frontend/extension/src/context/index.ts: -------------------------------------------------------------------------------- 1 | import useStorageContext from "./context"; 2 | import StorageContextProvider from "./provider"; 3 | 4 | export { useStorageContext, StorageContextProvider }; 5 | -------------------------------------------------------------------------------- /frontend/extension/src/context/provider.tsx: -------------------------------------------------------------------------------- 1 | import { Storage } from "@plasmohq/storage"; 2 | import { useEffect, useState } from "react"; 3 | import { StorageContext } from "./context"; 4 | 5 | interface Props { 6 | children: React.ReactNode; 7 | } 8 | 9 | const StorageContextProvider = ({ children }: Props) => { 10 | const storage = new Storage(); 11 | const [instanceUrl, setInstanceUrl] = useState(undefined); 12 | const [isInitialized, setIsInitialized] = useState(false); 13 | 14 | useEffect(() => { 15 | (async () => { 16 | const instanceUrl = await storage.get("instance_url"); 17 | 18 | setInstanceUrl(instanceUrl); 19 | setIsInitialized(true); 20 | })(); 21 | 22 | storage.watch({ 23 | instance_url: (c) => { 24 | setInstanceUrl(c.newValue); 25 | }, 26 | }); 27 | }, []); 28 | 29 | return ( 30 | storage.set("instance_url", instanceUrl), 34 | }} 35 | > 36 | {isInitialized && children} 37 | 38 | ); 39 | }; 40 | 41 | export default StorageContextProvider; 42 | -------------------------------------------------------------------------------- /frontend/extension/src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body, 6 | html, 7 | #root { 8 | @apply text-base dark:bg-zinc-900; 9 | font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans", "Noto Sans CJK SC", "Microsoft YaHei UI", "Microsoft YaHei", 10 | "WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", 11 | "Noto Color Emoji"; 12 | } 13 | 14 | @layer utilities { 15 | /* Hide scrollbar for Chrome, Safari and Opera */ 16 | .no-scrollbar::-webkit-scrollbar { 17 | display: none; 18 | } 19 | 20 | /* Hide scrollbar for IE, Edge and Firefox */ 21 | .no-scrollbar { 22 | -ms-overflow-style: none; /* IE and Edge */ 23 | scrollbar-width: none; /* Firefox */ 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/extension/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /** @type {import('tailwindcss').Config} */ 3 | module.exports = { 4 | mode: "jit", 5 | darkMode: "class", 6 | content: ["./**/*.tsx"], 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": ["node_modules"], 4 | "include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"], 5 | "compilerOptions": { 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | }, 9 | "baseUrl": "." 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/locales/README.md: -------------------------------------------------------------------------------- 1 | # Translation files 2 | 3 | This directory contains the translation files for the frontend including web and browser extension. 4 | -------------------------------------------------------------------------------- /frontend/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "About", 4 | "loading": "Loading", 5 | "cancel": "Cancel", 6 | "save": "Save", 7 | "create": "Create", 8 | "download": "Download", 9 | "edit": "Edit", 10 | "delete": "Delete", 11 | "language": "Language", 12 | "search": "Search", 13 | "email": "Email", 14 | "password": "Password", 15 | "account": "Account", 16 | "or": "Or" 17 | }, 18 | "auth": { 19 | "sign-in": "Sign in", 20 | "sign-up": "Sign up", 21 | "sign-out": "Sign out", 22 | "create-your-account": "Create your account", 23 | "host-tip": "You are registering as Admin.", 24 | "sign-in-with": "Sign in with {{provider}}" 25 | }, 26 | "analytics": { 27 | "self": "Analytics", 28 | "top-sources": "Top sources", 29 | "source": "Source", 30 | "visitors": "Visitors", 31 | "devices": "Devices", 32 | "browser": "Browser", 33 | "browsers": "Browsers", 34 | "operating-system": "Operating System" 35 | }, 36 | "shortcut": { 37 | "visits": "{{count}} visits", 38 | "visibility": { 39 | "workspace": { 40 | "self": "Workspace", 41 | "description": "Workspace members can access" 42 | }, 43 | "public": { 44 | "self": "Public", 45 | "description": "Public on the internet" 46 | } 47 | } 48 | }, 49 | "filter": { 50 | "all": "All", 51 | "personal": "Personal", 52 | "compact-mode": "Compact mode", 53 | "order-by": "Order by", 54 | "direction": "Direction" 55 | }, 56 | "user": { 57 | "self": "User", 58 | "nickname": "Nickname", 59 | "email": "Email", 60 | "role": "Role", 61 | "profile": "Profile" 62 | }, 63 | "settings": { 64 | "self": "Setting", 65 | "preference": { 66 | "self": "Preference", 67 | "color-theme": "Color theme" 68 | }, 69 | "workspace": { 70 | "self": "Workspace settings", 71 | "custom-style": "Custom style", 72 | "disallow-user-registration": { 73 | "self": "Disallow user registration" 74 | }, 75 | "default-visibility": "Default visibility", 76 | "member": { 77 | "self": "Member", 78 | "add": "Add member" 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/locales/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "À propos", 4 | "loading": "Chargement", 5 | "cancel": "Annuler", 6 | "save": "Sauver", 7 | "create": "Créer", 8 | "download": "Télécharger", 9 | "edit": "Modifier", 10 | "delete": "Supprimer", 11 | "language": "Langue", 12 | "search": "Recherche", 13 | "email": "E-mail", 14 | "password": "Mot de passe", 15 | "account": "Compte" 16 | }, 17 | "auth": { 18 | "sign-in": "Se connecter", 19 | "sign-up": "S'inscrire", 20 | "sign-out": "Se déconnecter", 21 | "create-your-account": "Créez votre compte" 22 | }, 23 | "analytics": { 24 | "self": "Analyse", 25 | "top-sources": "Principales sources", 26 | "source": "Source", 27 | "visitors": "Visiteurs", 28 | "devices": "Dispositifs", 29 | "browser": "Navigateur", 30 | "browsers": "Navigateurs", 31 | "operating-system": "Systèmes d'exploitation" 32 | }, 33 | "shortcut": { 34 | "visits": "{{count}} visites", 35 | "visibility": { 36 | "workspace": { 37 | "self": "Espace de travail", 38 | "description": "Les membres de l'espace de travail ont accès" 39 | }, 40 | "public": { 41 | "self": "Public", 42 | "description": "Visible par tous sur Internet" 43 | } 44 | } 45 | }, 46 | "filter": { 47 | "all": "Tout", 48 | "mine": "Le mien", 49 | "compact-mode": "Mode compact", 50 | "order-by": "Commandé par", 51 | "direction": "Direction" 52 | }, 53 | "user": { 54 | "self": "Utilisateur", 55 | "nickname": "Surnom", 56 | "email": "E-mail", 57 | "role": "Rôle", 58 | "profile": "Profil", 59 | "action": { 60 | "add-user": "Ajouter utilisateur" 61 | } 62 | }, 63 | "settings": { 64 | "self": "Paramètres", 65 | "preference": { 66 | "self": "Préférence", 67 | "color-theme": "Thème de couleur" 68 | }, 69 | "workspace": { 70 | "self": "Paramètres de l'espace de travail", 71 | "custom-style": "Style personnalisé", 72 | "enable-user-signup": { 73 | "self": "Activer l'inscription des utilisateurs", 74 | "description": "Une fois activé, d'autres utilisateurs peuvent s'inscrire." 75 | }, 76 | "default-visibility": "Visibilité par défaut" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/locales/hu.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "Névjegy", 4 | "loading": "Betöltés", 5 | "cancel": "Mégse", 6 | "save": "Mentés", 7 | "create": "Létrehozás", 8 | "download": "Letöltés", 9 | "edit": "Szerkesztés", 10 | "delete": "Törlés", 11 | "language": "Nyelv", 12 | "search": "Keresés", 13 | "email": "Email", 14 | "password": "Jelszó", 15 | "account": "Fiók" 16 | }, 17 | "auth": { 18 | "sign-in": "Bejelentkezés", 19 | "sign-up": "Regisztráció", 20 | "sign-out": "Kijelentkezés", 21 | "create-your-account": "Fiók létrehozás", 22 | "host-tip": "Adminisztrátorként regisztrál." 23 | }, 24 | "analytics": { 25 | "self": "Analitika", 26 | "top-sources": "Legfontosabb források", 27 | "source": "Forrás", 28 | "visitors": "Látogatók", 29 | "devices": "Eszközök", 30 | "browser": "Böngésző", 31 | "browsers": "Böngészők", 32 | "operating-system": "Operációs rendszer" 33 | }, 34 | "shortcut": { 35 | "visits": "{{count}} látogatás", 36 | "visibility": { 37 | "workspace": { 38 | "self": "Munkaterület", 39 | "description": "A munkaterület tagjai hozzáférhetnek" 40 | }, 41 | "public": { 42 | "self": "Nyilvános", 43 | "description": "Mindenki számára látható az interneten" 44 | } 45 | } 46 | }, 47 | "filter": { 48 | "all": "Összes", 49 | "mine": "Saját", 50 | "compact-mode": "Kompakt mód", 51 | "order-by": "Rendezés", 52 | "direction": "Irány" 53 | }, 54 | "user": { 55 | "self": "Felhasználó", 56 | "nickname": "Becenév", 57 | "email": "Email", 58 | "role": "Szerep", 59 | "profile": "Profil", 60 | "action": { 61 | "add-user": "Felhasználó hozzáadása" 62 | } 63 | }, 64 | "settings": { 65 | "self": "Beállítás", 66 | "preference": { 67 | "self": "Preferencia", 68 | "color-theme": "Színtéma" 69 | }, 70 | "workspace": { 71 | "self": "Munkaterület beállítások", 72 | "custom-style": "Egyéni stílus", 73 | "enable-user-signup": { 74 | "self": "Felhasználói regisztráció engedélyezése", 75 | "description": "Ha engedélyezve van, más felhasználók is regisztrálhatnak." 76 | }, 77 | "default-visibility": "Alapértelmezett láthatóság" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /frontend/locales/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "About", 4 | "loading": "読込中", 5 | "cancel": "取消", 6 | "save": "保存", 7 | "create": "作成", 8 | "download": "ダウンロード", 9 | "edit": "編集", 10 | "delete": "削除", 11 | "language": "言語", 12 | "search": "検索", 13 | "email": "Eメール", 14 | "password": "パスワード", 15 | "account": "アカウント", 16 | "or": "または" 17 | }, 18 | "auth": { 19 | "sign-in": "サインイン", 20 | "sign-up": "登録", 21 | "sign-out": "サインアウト", 22 | "create-your-account": "アカウントを作成してください", 23 | "host-tip": "管理者として登録されています。", 24 | "sign-in-with": "{{provider}}でサインイン" 25 | }, 26 | "analytics": { 27 | "self": "分析", 28 | "top-sources": "トップソース", 29 | "source": "ソース", 30 | "visitors": "訪問者", 31 | "devices": "デバイス", 32 | "browser": "ブラウザ", 33 | "browsers": "ブラウザ", 34 | "operating-system": "オペレーティングシステム" 35 | }, 36 | "shortcut": { 37 | "visits": "{{count}} 回訪問", 38 | "visibility": { 39 | "workspace": { 40 | "self": "ワークスペース", 41 | "description": "ワークスペースメンバーがアクセスできます" 42 | }, 43 | "public": { 44 | "self": "公開", 45 | "description": "誰でもアクセスできます" 46 | } 47 | } 48 | }, 49 | "filter": { 50 | "all": "全て", 51 | "personal": "個人", 52 | "compact-mode": "コンパクトモード", 53 | "order-by": "順序", 54 | "direction": "方向" 55 | }, 56 | "user": { 57 | "self": "ユーザー", 58 | "nickname": "ニックネーム", 59 | "email": "Eメール", 60 | "role": "役割", 61 | "profile": "プロフィール" 62 | }, 63 | "settings": { 64 | "self": "設定", 65 | "preference": { 66 | "self": "プリファレンス", 67 | "color-theme": "カラーテーマ" 68 | }, 69 | "workspace": { 70 | "self": "ワークスペースの設定", 71 | "custom-style": "カスタムスタイル", 72 | "disallow-user-registration": { 73 | "self": "ユーザーの登録を有効にする" 74 | }, 75 | "default-visibility": "デフォルトの表示", 76 | "member": { 77 | "self": "メンバー", 78 | "add": "メンバーを追加" 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/locales/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "Информация", 4 | "loading": "Загружается", 5 | "cancel": "Отменить", 6 | "save": "Сохранить", 7 | "create": "Создать", 8 | "download": "Загрузить", 9 | "edit": "Редактировать", 10 | "delete": "Удалить", 11 | "language": "Язык", 12 | "search": "Поиск", 13 | "email": "Email", 14 | "password": "Пароль", 15 | "account": "Аккаунт" 16 | }, 17 | "auth": { 18 | "sign-in": "Войти", 19 | "sign-up": "Регистрация", 20 | "sign-out": "Выйти", 21 | "create-your-account": "Создать аккаунт", 22 | "host-tip": "Вы зарегистрированы как Admin." 23 | }, 24 | "analytics": { 25 | "self": "Аналитика", 26 | "top-sources": "Лучшие источники", 27 | "source": "Источник", 28 | "visitors": "Посетители", 29 | "devices": "Устройства", 30 | "browser": "Браузер", 31 | "browsers": "Браузеры", 32 | "operating-system": "Операционная система" 33 | }, 34 | "shortcut": { 35 | "visits": "{{count}} перехода", 36 | "visibility": { 37 | "workspace": { 38 | "self": "Команда", 39 | "description": "Члены команды имеют доступ" 40 | }, 41 | "public": { 42 | "self": "Публичная", 43 | "description": "Видимая для всех из интернета" 44 | } 45 | } 46 | }, 47 | "filter": { 48 | "all": "Все", 49 | "mine": "Мои", 50 | "compact-mode": "Компактный режим", 51 | "order-by": "Создана", 52 | "direction": "Путь" 53 | }, 54 | "user": { 55 | "self": "Пользователь", 56 | "nickname": "Имя пользователя", 57 | "email": "Email", 58 | "role": "Роль", 59 | "profile": "Профиль", 60 | "action": { 61 | "add-user": "Добавить пользователя" 62 | } 63 | }, 64 | "settings": { 65 | "self": "Настройки", 66 | "preference": { 67 | "self": "Внешний вид", 68 | "color-theme": "Цветовая схема" 69 | }, 70 | "workspace": { 71 | "self": "Настройки команды", 72 | "custom-style": "Пользовательский стиль", 73 | "enable-user-signup": { 74 | "self": "Разрешить регистрацию пользователей", 75 | "description": "После включения, другие пользователи смогут зарегистрироваться." 76 | }, 77 | "default-visibility": "Отображение по умолчанию" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /frontend/locales/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "Hakkında", 4 | "loading": "Yükleniyor", 5 | "cancel": "İptal", 6 | "save": "Kaydet", 7 | "create": "Oluştur", 8 | "download": "İndir", 9 | "edit": "Düzenle", 10 | "delete": "Sil", 11 | "language": "Dil", 12 | "search": "Ara", 13 | "email": "E-posta", 14 | "password": "Şifre", 15 | "account": "Hesap" 16 | }, 17 | "auth": { 18 | "sign-in": "Giriş yap", 19 | "sign-up": "Kaydol", 20 | "sign-out": "Çıkış yap", 21 | "create-your-account": "Hesabınızı oluşturun" 22 | }, 23 | "analytics": { 24 | "self": "Analizler", 25 | "top-sources": "En İyi Kaynaklar", 26 | "source": "Kaynak", 27 | "visitors": "Ziyâretçiler", 28 | "devices": "Cihazlar", 29 | "browser": "Tarayıcı", 30 | "browsers": "Tarayıcılar", 31 | "operating-system": "İşletim Sistemi" 32 | }, 33 | "shortcut": { 34 | "visits": "{{count}} ziyaret", 35 | "visibility": { 36 | "workspace": { 37 | "self": "Çalışma Alanı", 38 | "description": "Çalışma alanı üyeleri erişebilir" 39 | }, 40 | "public": { 41 | "self": "Herkese açık", 42 | "description": "İnternette herkese görünür" 43 | } 44 | } 45 | }, 46 | "filter": { 47 | "all": "Hepsi", 48 | "mine": "Benim", 49 | "compact-mode": "Kompakt mod", 50 | "order-by": "Sırala", 51 | "direction": "Yön" 52 | }, 53 | "user": { 54 | "self": "Kullanıcı", 55 | "nickname": "Takma ad", 56 | "email": "E-posta", 57 | "role": "Rol", 58 | "profile": "Profil", 59 | "action": { 60 | "add-user": "Kullanıcı ekle" 61 | } 62 | }, 63 | "settings": { 64 | "self": "Ayarlar", 65 | "preference": { 66 | "self": "Tercihler", 67 | "color-theme": "Renk teması" 68 | }, 69 | "workspace": { 70 | "self": "Çalışma alanı ayarları", 71 | "custom-style": "Özel stil", 72 | "enable-user-signup": { 73 | "self": "Kullanıcı kaydını etkinleştir", 74 | "description": "Etkinleştirildiğinde, diğer kullanıcılar kaydolabilir." 75 | }, 76 | "default-visibility": "Varsayılan görünürlük" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/locales/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "Про", 4 | "loading": "Завантаження", 5 | "cancel": "Скасувати", 6 | "save": "Зберегти", 7 | "create": "Створити", 8 | "download": "Завантажити", 9 | "edit": "Редагувати", 10 | "delete": "Видалити", 11 | "language": "Мова", 12 | "search": "Пошук", 13 | "email": "Електронна пошта", 14 | "password": "Пароль", 15 | "account": "Обліковий запис", 16 | "or": "Або" 17 | }, 18 | "auth": { 19 | "sign-in": "Увійдіть", 20 | "sign-up": "Зареєструватися", 21 | "sign-out": "Вийти", 22 | "create-your-account": "Створіть свій акаунт", 23 | "host-tip": "Ви реєструєтесь як адміністратор.", 24 | "sign-in-with": "Увійдіть за допомогою {{provider}}" 25 | }, 26 | "analytics": { 27 | "self": "Аналітика", 28 | "top-sources": "Найпопулярніші джерела", 29 | "source": "Джерело", 30 | "visitors": "Відвідувачі", 31 | "devices": "Пристрої", 32 | "browser": "Браузер", 33 | "browsers": "Браузери", 34 | "operating-system": "Операційна система" 35 | }, 36 | "shortcut": { 37 | "visits": "{{count}} відвідувань", 38 | "visibility": { 39 | "workspace": { 40 | "self": "Робоча область", 41 | "description": "Учасники робочої області мають доступ" 42 | }, 43 | "public": { 44 | "self": "Відкритий", 45 | "description": "Відкрито в Інтернеті" 46 | } 47 | } 48 | }, 49 | "filter": { 50 | "all": "Все", 51 | "personal": "Особисті", 52 | "compact-mode": "Компактний режим", 53 | "order-by": "Сортувати за", 54 | "direction": "Напрямок" 55 | }, 56 | "user": { 57 | "self": "Користувач", 58 | "nickname": "Псевдонім", 59 | "email": "Електронна пошта", 60 | "role": "Роль", 61 | "profile": "Профіль" 62 | }, 63 | "settings": { 64 | "self": "Налаштування", 65 | "preference": { 66 | "self": "Вибір", 67 | "color-theme": "Кольорова тема" 68 | }, 69 | "workspace": { 70 | "self": "Налаштування робочого простору", 71 | "custom-style": "Індивідуальний стиль", 72 | "disallow-user-registration": { 73 | "self": "Заборонити реєстрацію користувача" 74 | }, 75 | "default-visibility": "Видимість за замовченям", 76 | "member": { 77 | "self": "Учасник", 78 | "add": "Додати учасника" 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "about": "关于", 4 | "loading": "加载中", 5 | "cancel": "取消", 6 | "save": "保存", 7 | "create": "创建", 8 | "download": "下载", 9 | "edit": "编辑", 10 | "delete": "删除", 11 | "language": "语言", 12 | "search": "搜索", 13 | "email": "邮箱", 14 | "password": "密码", 15 | "account": "账号" 16 | }, 17 | "auth": { 18 | "sign-in": "登录", 19 | "sign-up": "注册", 20 | "sign-out": "退出登录", 21 | "create-your-account": "创建账号" 22 | }, 23 | "analytics": { 24 | "self": "分析", 25 | "top-sources": "热门来源", 26 | "source": "来源", 27 | "visitors": "访客数", 28 | "devices": "设备", 29 | "browser": "浏览器", 30 | "browsers": "浏览器", 31 | "operating-system": "操作系统" 32 | }, 33 | "shortcut": { 34 | "visits": "{{count}} 次访问", 35 | "visibility": { 36 | "workspace": { 37 | "self": "工作区", 38 | "description": "工作区成员可以访问" 39 | }, 40 | "public": { 41 | "self": "公开的", 42 | "description": "公开至互联网" 43 | } 44 | } 45 | }, 46 | "filter": { 47 | "all": "所有", 48 | "personal": "我的", 49 | "compact-mode": "紧凑模式", 50 | "order-by": "排序方式", 51 | "direction": "方向" 52 | }, 53 | "user": { 54 | "self": "用户", 55 | "nickname": "昵称", 56 | "email": "邮箱", 57 | "role": "角色", 58 | "profile": "账号", 59 | "action": { 60 | "add-user": "添加用户" 61 | } 62 | }, 63 | "settings": { 64 | "self": "设置", 65 | "preference": { 66 | "self": "偏好设置", 67 | "color-theme": "主题" 68 | }, 69 | "workspace": { 70 | "self": "系统设置", 71 | "custom-style": "自定义样式", 72 | "enable-user-signup": { 73 | "self": "启用用户注册", 74 | "description": "允许其他用户注册新账号" 75 | }, 76 | "default-visibility": "默认可见性" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "jsx": true 11 | }, 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": ["react", "@typescript-eslint", "prettier"], 16 | "ignorePatterns": ["node_modules", "dist", "public"], 17 | "rules": { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | "endOfLine": "auto" 22 | } 23 | ], 24 | "@typescript-eslint/no-explicit-any": ["off"], 25 | "react/react-in-jsx-scope": "off", 26 | "react/jsx-no-target-blank": "off" 27 | }, 28 | "settings": { 29 | "react": { 30 | "version": "detect" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | src/types/proto 7 | -------------------------------------------------------------------------------- /frontend/web/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 140, 3 | useTabs: false, 4 | semi: true, 5 | singleQuote: false, 6 | plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")], 7 | importOrder: ["", "", "^@/((?!less).+)", "^[./]", "^(.+).less"], 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/web/README.md: -------------------------------------------------------------------------------- 1 | # Slash 2 | -------------------------------------------------------------------------------- /frontend/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Slash 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slash", 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build", 6 | "serve": "vite preview", 7 | "lint": "eslint --ext .js,.ts,.tsx, src", 8 | "lint-fix": "eslint --ext .js,.ts,.tsx, src --fix", 9 | "type-check": "tsc --noEmit --skipLibCheck", 10 | "postinstall": "cd ../../proto && buf generate" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.14.0", 14 | "@emotion/styled": "^11.14.0", 15 | "@mui/joy": "5.0.0-beta.48", 16 | "@reduxjs/toolkit": "^2.8.1", 17 | "classnames": "^2.5.1", 18 | "copy-to-clipboard": "^3.3.3", 19 | "dayjs": "^1.11.13", 20 | "i18next": "^24.2.3", 21 | "lodash-es": "^4.17.21", 22 | "lucide-react": "^0.469.0", 23 | "nice-grpc-web": "^3.3.7", 24 | "qrcode.react": "^4.2.0", 25 | "react": "^18.3.1", 26 | "react-dom": "^18.3.1", 27 | "react-hot-toast": "^2.5.2", 28 | "react-i18next": "^15.5.1", 29 | "react-router-dom": "^7.6.0", 30 | "react-use": "^17.6.0", 31 | "tailwindcss": "^3.4.17", 32 | "uuid": "^11.1.0", 33 | "zustand": "^5.0.4" 34 | }, 35 | "devDependencies": { 36 | "@bufbuild/buf": "^1.53.0", 37 | "@bufbuild/protobuf": "^2.3.0", 38 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 39 | "@types/lodash-es": "^4.17.12", 40 | "@types/react": "^18.3.21", 41 | "@types/react-dom": "^18.3.7", 42 | "@types/uuid": "^10.0.0", 43 | "@typescript-eslint/eslint-plugin": "^7.18.0", 44 | "@typescript-eslint/parser": "^7.18.0", 45 | "@vitejs/plugin-react-swc": "^3.9.0", 46 | "autoprefixer": "^10.4.21", 47 | "eslint": "^8.57.1", 48 | "eslint-config-prettier": "^9.1.0", 49 | "eslint-plugin-prettier": "^5.4.0", 50 | "eslint-plugin-react": "^7.37.5", 51 | "long": "^5.3.2", 52 | "postcss": "^8.5.3", 53 | "prettier": "^3.5.3", 54 | "protobufjs": "^7.5.1", 55 | "typescript": "^5.8.3", 56 | "vite": "^6.3.5" 57 | }, 58 | "resolutions": { 59 | "csstype": "3.1.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/web/public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/web/src/components/AboutDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Link, Modal, ModalDialog } from "@mui/joy"; 2 | import { useTranslation } from "react-i18next"; 3 | import Icon from "./Icon"; 4 | 5 | interface Props { 6 | onClose: () => void; 7 | } 8 | 9 | const AboutDialog: React.FC = (props: Props) => { 10 | const { onClose } = props; 11 | const { t } = useTranslation(); 12 | 13 | return ( 14 | 15 | 16 |
17 | {t("common.about")} 18 | 21 |
22 |
23 |

24 | Slash is an open source, self-hosted platform for sharing and managing your most frequently 25 | used links. 26 |

27 |
28 | Source code: 29 | 30 | GitHub 31 | 32 |
33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | export default AboutDialog; 40 | -------------------------------------------------------------------------------- /frontend/web/src/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal, ModalDialog } from "@mui/joy"; 2 | import { createRoot } from "react-dom/client"; 3 | import Icon from "./Icon"; 4 | 5 | type AlertStyle = "primary" | "warning" | "danger"; 6 | 7 | interface Props { 8 | title: string; 9 | content: string; 10 | style?: AlertStyle; 11 | closeBtnText?: string; 12 | confirmBtnText?: string; 13 | onClose?: () => void; 14 | onConfirm?: () => void; 15 | } 16 | 17 | const defaultProps: Props = { 18 | title: "", 19 | content: "", 20 | style: "primary", 21 | closeBtnText: "Close", 22 | confirmBtnText: "Confirm", 23 | onClose: () => null, 24 | onConfirm: () => null, 25 | }; 26 | 27 | const Alert: React.FC = (props: Props) => { 28 | const { title, content, closeBtnText, confirmBtnText, onClose, onConfirm, style } = { 29 | ...defaultProps, 30 | ...props, 31 | }; 32 | 33 | const handleCloseBtnClick = () => { 34 | if (onClose) { 35 | onClose(); 36 | } 37 | }; 38 | 39 | const handleConfirmBtnClick = async () => { 40 | if (onConfirm) { 41 | onConfirm(); 42 | } 43 | }; 44 | 45 | return ( 46 | 47 | 48 |
49 | {title} 50 | 53 |
54 |
55 |

{content}

56 |
57 | 60 | 63 |
64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export const showCommonDialog = (props: Props) => { 71 | const tempDiv = document.createElement("div"); 72 | const dialog = createRoot(tempDiv); 73 | document.body.append(tempDiv); 74 | 75 | const destory = () => { 76 | dialog.unmount(); 77 | tempDiv.remove(); 78 | }; 79 | 80 | const onClose = () => { 81 | if (props.onClose) { 82 | props.onClose(); 83 | } 84 | destory(); 85 | }; 86 | 87 | const onConfirm = () => { 88 | if (props.onConfirm) { 89 | props.onConfirm(); 90 | } 91 | destory(); 92 | }; 93 | 94 | dialog.render(); 95 | }; 96 | -------------------------------------------------------------------------------- /frontend/web/src/components/BetaBadge.tsx: -------------------------------------------------------------------------------- 1 | const BetaBadge = () => { 2 | return ( 3 |
4 | Beta 5 |
6 | ); 7 | }; 8 | 9 | export default BetaBadge; 10 | -------------------------------------------------------------------------------- /frontend/web/src/components/DemoBanner.tsx: -------------------------------------------------------------------------------- 1 | import { useWorkspaceStore } from "@/stores"; 2 | import Icon from "./Icon"; 3 | 4 | const DemoBanner: React.FC = () => { 5 | const workspaceStore = useWorkspaceStore(); 6 | const shouldShow = workspaceStore.profile.mode === "demo"; 7 | 8 | if (!shouldShow) return null; 9 | 10 | return ( 11 |
12 |
13 | ✨🔗 Slash - An open source, self-hosted platform for sharing and managing your most frequently used links. 14 | 19 | Install 20 | 21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default DemoBanner; 28 | -------------------------------------------------------------------------------- /frontend/web/src/components/FeatureBadge.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "@mui/joy"; 2 | import { useWorkspaceStore } from "@/stores"; 3 | import { FeatureType } from "@/stores/workspace"; 4 | import Icon from "./Icon"; 5 | 6 | interface Props { 7 | feature: FeatureType; 8 | className?: string; 9 | } 10 | 11 | const FeatureBadge = ({ feature, className }: Props) => { 12 | const workspaceStore = useWorkspaceStore(); 13 | const isFeatureEnabled = workspaceStore.checkFeatureAvailable(feature); 14 | 15 | if (isFeatureEnabled) { 16 | return null; 17 | } 18 | return ( 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default FeatureBadge; 26 | -------------------------------------------------------------------------------- /frontend/web/src/components/FilterView.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useViewStore } from "@/stores"; 3 | import Icon from "./Icon"; 4 | import VisibilityIcon from "./VisibilityIcon"; 5 | 6 | const FilterView = () => { 7 | const { t } = useTranslation(); 8 | const viewStore = useViewStore(); 9 | const filter = viewStore.filter; 10 | const shouldShowFilters = filter.tag !== undefined || filter.visibility !== undefined; 11 | 12 | if (!shouldShowFilters) { 13 | return <>; 14 | } 15 | 16 | return ( 17 |
18 | Filters: 19 | {filter.tag && ( 20 | 28 | )} 29 | {filter.visibility && ( 30 | 38 | )} 39 |
40 | ); 41 | }; 42 | 43 | export default FilterView; 44 | -------------------------------------------------------------------------------- /frontend/web/src/components/GenerateQRCodeDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal, ModalDialog } from "@mui/joy"; 2 | import { QRCodeCanvas } from "qrcode.react"; 3 | import { useRef } from "react"; 4 | import { toast } from "react-hot-toast"; 5 | import { useTranslation } from "react-i18next"; 6 | import { absolutifyLink } from "@/helpers/utils"; 7 | import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; 8 | import Icon from "./Icon"; 9 | 10 | interface Props { 11 | shortcut: Shortcut; 12 | onClose: () => void; 13 | } 14 | 15 | const GenerateQRCodeDialog: React.FC = (props: Props) => { 16 | const { shortcut, onClose } = props; 17 | const { t } = useTranslation(); 18 | const containerRef = useRef(null); 19 | const shortcutLink = absolutifyLink(`/s/${shortcut.name}`); 20 | 21 | const handleCloseBtnClick = () => { 22 | onClose(); 23 | }; 24 | 25 | const handleDownloadQRCodeClick = () => { 26 | const canvas = containerRef.current?.querySelector("canvas"); 27 | if (!canvas) { 28 | toast.error("Failed to get QR code canvas"); 29 | return; 30 | } 31 | 32 | const link = document.createElement("a"); 33 | link.download = `${shortcut.title || shortcut.name}-qrcode.png`; 34 | link.href = canvas.toDataURL(); 35 | link.click(); 36 | handleCloseBtnClick(); 37 | }; 38 | 39 | return ( 40 | 41 | 42 |
43 | QR Code 44 | 47 |
48 |
49 |
50 | 51 |
52 |
53 | 57 |
58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default GenerateQRCodeDialog; 65 | -------------------------------------------------------------------------------- /frontend/web/src/components/Icon.ts: -------------------------------------------------------------------------------- 1 | import * as Icon from "lucide-react"; 2 | 3 | export default Icon; 4 | -------------------------------------------------------------------------------- /frontend/web/src/components/LinkFavicon.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Icon from "./Icon"; 3 | 4 | interface Props { 5 | url: string; 6 | } 7 | 8 | const getFaviconUrlWithProvider = (url: string, provider: string) => { 9 | try { 10 | const searchParams = new URLSearchParams(); 11 | searchParams.set("domain", new URL(url).hostname); 12 | searchParams.set("sz", "64"); 13 | return new URL(`?${searchParams.toString()}`, provider).toString(); 14 | } catch (error) { 15 | return ""; 16 | } 17 | }; 18 | 19 | const LinkFavicon = (props: Props) => { 20 | const { url } = props; 21 | const faviconProvider = "https://www.google.com/s2/favicons"; 22 | const [faviconUrl, setFaviconUrl] = useState(getFaviconUrlWithProvider(url, faviconProvider)); 23 | 24 | const handleImgError = () => { 25 | setFaviconUrl(""); 26 | }; 27 | 28 | return faviconUrl ? ( 29 | 30 | ) : ( 31 | 32 | ); 33 | }; 34 | 35 | export default LinkFavicon; 36 | -------------------------------------------------------------------------------- /frontend/web/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { useWorkspaceStore } from "@/stores"; 3 | import { FeatureType } from "@/stores/workspace"; 4 | import Icon from "./Icon"; 5 | 6 | interface Props { 7 | className?: string; 8 | } 9 | 10 | const Logo = ({ className }: Props) => { 11 | const workspaceStore = useWorkspaceStore(); 12 | const hasCustomBranding = workspaceStore.checkFeatureAvailable(FeatureType.CustomeBranding); 13 | const branding = hasCustomBranding && workspaceStore.setting.branding ? new TextDecoder().decode(workspaceStore.setting.branding) : ""; 14 | return ( 15 |
16 | {branding ? ( 17 | branding 18 | ) : ( 19 | 20 | )} 21 |
22 | ); 23 | }; 24 | 25 | export default Logo; 26 | -------------------------------------------------------------------------------- /frontend/web/src/components/Navigator.tsx: -------------------------------------------------------------------------------- 1 | const Navigator = () => { 2 | return <>; 3 | }; 4 | 5 | export default Navigator; 6 | -------------------------------------------------------------------------------- /frontend/web/src/components/ResourceNameInput.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Input } from "@mui/joy"; 2 | import classNames from "classnames"; 3 | import { useEffect, useState } from "react"; 4 | import { generateRandomString } from "@/helpers/utils"; 5 | import Icon from "./Icon"; 6 | 7 | interface Props { 8 | name: string; 9 | onChange: (name: string) => void; 10 | } 11 | 12 | const ResourceNameInput = (props: Props) => { 13 | const { name, onChange } = props; 14 | const [modified, setModified] = useState(false); 15 | const [editingName, setEditingName] = useState(name || generateRandomString().toLowerCase()); 16 | 17 | useEffect(() => { 18 | onChange(editingName); 19 | }, [editingName]); 20 | 21 | const handleNameInputChange = (e: React.ChangeEvent) => { 22 | if (!modified) { 23 | return; 24 | } 25 | 26 | setEditingName(e.target.value); 27 | }; 28 | 29 | return ( 30 |
31 |
32 | Name 33 | {modified ? ( 34 | * 35 | ) : ( 36 | <> 37 | : 38 | {editingName} 39 |
40 | setModified(true)}> 41 | 42 | 43 | setEditingName(generateRandomString().toLowerCase())}> 44 | 45 | 46 |
47 | 48 | )} 49 |
50 | {modified && ( 51 |
52 | 53 |
54 | )} 55 |
56 | ); 57 | }; 58 | 59 | export default ResourceNameInput; 60 | -------------------------------------------------------------------------------- /frontend/web/src/components/ShortcutFrame.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from "@mui/joy"; 2 | import classNames from "classnames"; 3 | import { Link } from "react-router-dom"; 4 | import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; 5 | import Icon from "./Icon"; 6 | import LinkFavicon from "./LinkFavicon"; 7 | 8 | interface Props { 9 | shortcut: Shortcut; 10 | } 11 | 12 | const ShortcutFrame = ({ shortcut }: Props) => { 13 | return ( 14 |
15 | 20 |
21 | 22 |
23 |

{shortcut.title || shortcut.name}

24 |

{shortcut.description}

25 | 26 |

27 | Open this site in a new tab 28 | 29 |

30 | 31 |
32 | ); 33 | }; 34 | 35 | export default ShortcutFrame; 36 | -------------------------------------------------------------------------------- /frontend/web/src/components/ShortcutView.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { Link } from "react-router-dom"; 3 | import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; 4 | import Icon from "./Icon"; 5 | import LinkFavicon from "./LinkFavicon"; 6 | import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; 7 | 8 | interface Props { 9 | shortcut: Shortcut; 10 | className?: string; 11 | showActions?: boolean; 12 | alwaysShowLink?: boolean; 13 | onClick?: () => void; 14 | } 15 | 16 | const ShortcutView = (props: Props) => { 17 | const { shortcut, className, showActions, alwaysShowLink, onClick } = props; 18 | 19 | return ( 20 |
27 |
28 | 29 |
30 |
31 | {shortcut.title ? ( 32 | <> 33 | {shortcut.title} 34 | ({shortcut.name}) 35 | 36 | ) : ( 37 | <> 38 | {shortcut.name} 39 | 40 | )} 41 |
42 | e.stopPropagation()} 50 | > 51 | 52 | 53 | {showActions && ( 54 |
e.stopPropagation()}> 55 | 56 |
57 | )} 58 |
59 | ); 60 | }; 61 | 62 | export default ShortcutView; 63 | -------------------------------------------------------------------------------- /frontend/web/src/components/ShortcutsContainer.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import useNavigateTo from "@/hooks/useNavigateTo"; 3 | import { useViewStore } from "@/stores"; 4 | import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; 5 | import ShortcutCard from "./ShortcutCard"; 6 | import ShortcutView from "./ShortcutView"; 7 | 8 | interface Props { 9 | shortcutList: Shortcut[]; 10 | } 11 | 12 | const ShortcutsContainer: React.FC = (props: Props) => { 13 | const { shortcutList } = props; 14 | const navigateTo = useNavigateTo(); 15 | const viewStore = useViewStore(); 16 | const displayStyle = viewStore.displayStyle || "full"; 17 | const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard; 18 | 19 | const handleShortcutClick = (shortcut: Shortcut) => { 20 | navigateTo(`/shortcut/${shortcut.id}`); 21 | }; 22 | 23 | return ( 24 |
30 | {shortcutList.map((shortcut) => { 31 | return handleShortcutClick(shortcut)} />; 32 | })} 33 |
34 | ); 35 | }; 36 | 37 | export default ShortcutsContainer; 38 | -------------------------------------------------------------------------------- /frontend/web/src/components/SubscriptionFAQ.tsx: -------------------------------------------------------------------------------- 1 | import Accordion from "@mui/joy/Accordion"; 2 | import AccordionDetails from "@mui/joy/AccordionDetails"; 3 | import AccordionGroup from "@mui/joy/AccordionGroup"; 4 | import AccordionSummary from "@mui/joy/AccordionSummary"; 5 | 6 | const SubscriptionFAQ = () => { 7 | return ( 8 |
9 |

Frequently Asked Questions

10 | 11 | 12 | Can I use the Free plan in my team? 13 | 14 | Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you should upgrade to the 15 | Pro plan. 16 | 17 | 18 | 19 | How many devices can the license key be used on? 20 | {`It's unlimited for now, but please do not abuse it.`} 21 | 22 | 23 | {`Can I get a refund if Slash doesn't meet my needs?`} 24 | 25 | Yes, absolutely! You can contact us with `yourselfhosted@gmail.com`. I will refund you as soon as possible. 26 | 27 | 28 | 29 | Is there a Lifetime license? 30 | 31 | {`As software requires someone to maintain it, so we won't sell a lifetime service, since humans are not immortal yet. But if you 32 | really want it, please contact us "yourselfhosted@gmail.com".`} 33 | 34 | 35 | 36 |
37 | ); 38 | }; 39 | 40 | export default SubscriptionFAQ; 41 | -------------------------------------------------------------------------------- /frontend/web/src/components/ViewSetting.tsx: -------------------------------------------------------------------------------- 1 | import { Divider, Option, Select, Switch } from "@mui/joy"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useViewStore } from "@/stores"; 4 | import Icon from "./Icon"; 5 | import Dropdown from "./common/Dropdown"; 6 | 7 | const ViewSetting = () => { 8 | const { t } = useTranslation(); 9 | const viewStore = useViewStore(); 10 | const order = viewStore.getOrder(); 11 | const { field, direction } = order; 12 | const displayStyle = viewStore.displayStyle || "full"; 13 | 14 | return ( 15 | 18 | 19 | 20 | } 21 | actionsClassName="!mt-3 !right-[unset] -left-24 -ml-2" 22 | actions={ 23 |
e.stopPropagation()}> 24 |
25 | {t("filter.compact-mode")} 26 | viewStore.setDisplayStyle(event.target.checked ? "compact" : "full")} 30 | /> 31 |
32 | 33 |
34 | {t("filter.order-by")} 35 | 41 |
42 |
43 | {t("filter.direction")} 44 | 48 |
49 |
50 | } 51 | >
52 | ); 53 | }; 54 | 55 | export default ViewSetting; 56 | -------------------------------------------------------------------------------- /frontend/web/src/components/VisibilityIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Visibility } from "@/types/proto/api/v1/common"; 2 | import Icon from "./Icon"; 3 | 4 | interface Props { 5 | visibility: Visibility; 6 | className?: string; 7 | } 8 | 9 | const VisibilityIcon = (props: Props) => { 10 | const { visibility, className } = props; 11 | if (visibility === Visibility.WORKSPACE) { 12 | return ; 13 | } else if (visibility === Visibility.PUBLIC) { 14 | return ; 15 | } 16 | return null; 17 | }; 18 | 19 | export default VisibilityIcon; 20 | -------------------------------------------------------------------------------- /frontend/web/src/components/common/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useRef, useState } from "react"; 2 | import Icon from "@/components/Icon"; 3 | 4 | interface Props { 5 | trigger?: ReactNode; 6 | actions?: ReactNode; 7 | className?: string; 8 | actionsClassName?: string; 9 | } 10 | 11 | const Dropdown: React.FC = (props: Props) => { 12 | const { trigger, actions, className, actionsClassName } = props; 13 | const [dropdownStatus, setDropdownStatus] = useState(false); 14 | const dropdownWrapperRef = useRef(null); 15 | 16 | useEffect(() => { 17 | if (dropdownStatus) { 18 | const handleClickOutside = (event: MouseEvent) => { 19 | if (!dropdownWrapperRef.current?.contains(event.target as Node)) { 20 | setDropdownStatus(false); 21 | } 22 | }; 23 | 24 | window.addEventListener("click", handleClickOutside, { 25 | capture: true, 26 | }); 27 | return () => { 28 | window.removeEventListener("click", handleClickOutside, { 29 | capture: true, 30 | }); 31 | }; 32 | } 33 | }, [dropdownStatus]); 34 | 35 | const handleToggleDropdownStatus = (e: React.MouseEvent) => { 36 | e.stopPropagation(); 37 | setDropdownStatus(!dropdownStatus); 38 | }; 39 | 40 | return ( 41 |
46 | {trigger ? ( 47 | trigger 48 | ) : ( 49 | 52 | )} 53 |
58 | {actions} 59 |
60 |
61 | ); 62 | }; 63 | 64 | export default Dropdown; 65 | -------------------------------------------------------------------------------- /frontend/web/src/components/setting/AccountSection.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@mui/joy"; 2 | import { useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import ChangePasswordDialog from "@/components/ChangePasswordDialog"; 5 | import EditUserinfoDialog from "@/components/EditUserinfoDialog"; 6 | import { useUserStore } from "@/stores"; 7 | import { Role } from "@/types/proto/api/v1/user_service"; 8 | 9 | const AccountSection: React.FC = () => { 10 | const { t } = useTranslation(); 11 | const currentUser = useUserStore().getCurrentUser(); 12 | const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState(false); 13 | const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false); 14 | const isAdmin = currentUser.role === Role.ADMIN; 15 | 16 | return ( 17 | <> 18 |
19 |

{t("common.account")}

20 |

21 | {currentUser.nickname} 22 | {isAdmin && Admin} 23 |

24 |

25 | {t("common.email")}: 26 | {currentUser.email} 27 |

28 |
29 | 32 | 35 |
36 |
37 | 38 | {showEditUserinfoDialog && setShowEditUserinfoDialog(false)} />} 39 | 40 | {showChangePasswordDialog && setShowChangePasswordDialog(false)} />} 41 | 42 | ); 43 | }; 44 | 45 | export default AccountSection; 46 | -------------------------------------------------------------------------------- /frontend/web/src/css/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body, 6 | html, 7 | #root { 8 | @apply text-base w-full h-full dark:bg-zinc-900; 9 | } 10 | 11 | @layer utilities { 12 | /* Hide scrollbar for Chrome, Safari and Opera */ 13 | .no-scrollbar::-webkit-scrollbar { 14 | display: none; 15 | } 16 | 17 | /* Hide scrollbar for IE, Edge and Firefox */ 18 | .no-scrollbar { 19 | -ms-overflow-style: none; /* IE and Edge */ 20 | scrollbar-width: none; /* Firefox */ 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/web/src/css/joy-ui.css: -------------------------------------------------------------------------------- 1 | .MuiDrawer-content { 2 | @apply !w-auto; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/web/src/grpcweb.ts: -------------------------------------------------------------------------------- 1 | import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web"; 2 | import { AuthServiceDefinition } from "./types/proto/api/v1/auth_service"; 3 | import { CollectionServiceDefinition } from "./types/proto/api/v1/collection_service"; 4 | import { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_service"; 5 | import { SubscriptionServiceDefinition } from "./types/proto/api/v1/subscription_service"; 6 | import { UserServiceDefinition } from "./types/proto/api/v1/user_service"; 7 | import { UserSettingServiceDefinition } from "./types/proto/api/v1/user_setting_service"; 8 | import { WorkspaceServiceDefinition } from "./types/proto/api/v1/workspace_service"; 9 | 10 | const address = import.meta.env.MODE === "development" ? "http://localhost:8082" : window.location.origin; 11 | 12 | const channel = createChannel( 13 | address, 14 | FetchTransport({ 15 | credentials: "include", 16 | }), 17 | ); 18 | 19 | const clientFactory = createClientFactory(); 20 | 21 | export const workspaceServiceClient = clientFactory.create(WorkspaceServiceDefinition, channel); 22 | 23 | export const subscriptionServiceClient = clientFactory.create(SubscriptionServiceDefinition, channel); 24 | 25 | export const authServiceClient = clientFactory.create(AuthServiceDefinition, channel); 26 | 27 | export const userServiceClient = clientFactory.create(UserServiceDefinition, channel); 28 | 29 | export const userSettingServiceClient = clientFactory.create(UserSettingServiceDefinition, channel); 30 | 31 | export const shortcutServiceClient = clientFactory.create(ShortcutServiceDefinition, channel); 32 | 33 | export const collectionServiceClient = clientFactory.create(CollectionServiceDefinition, channel); 34 | -------------------------------------------------------------------------------- /frontend/web/src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | export const absolutifyLink = (rel: string): string => { 2 | const anchor = document.createElement("a"); 3 | anchor.setAttribute("href", rel); 4 | return anchor.href; 5 | }; 6 | 7 | export const isURL = (str: string): boolean => { 8 | const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i; 9 | return urlRegex.test(str); 10 | }; 11 | 12 | export const generateRandomString = () => { 13 | const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 14 | let randomString = ""; 15 | for (let i = 0; i < 6; i++) { 16 | const randomIndex = Math.floor(Math.random() * characters.length); 17 | randomString += characters.charAt(randomIndex); 18 | } 19 | return randomString; 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/web/src/hooks/useLoading.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const useLoading = (initialState = true) => { 4 | const [state, setState] = useState({ isLoading: initialState, isFailed: false, isSucceed: false }); 5 | 6 | return { 7 | ...state, 8 | setLoading: () => { 9 | setState({ 10 | ...state, 11 | isLoading: true, 12 | isFailed: false, 13 | isSucceed: false, 14 | }); 15 | }, 16 | setFinish: () => { 17 | setState({ 18 | ...state, 19 | isLoading: false, 20 | isFailed: false, 21 | isSucceed: true, 22 | }); 23 | }, 24 | setError: () => { 25 | setState({ 26 | ...state, 27 | isLoading: false, 28 | isFailed: true, 29 | isSucceed: false, 30 | }); 31 | }, 32 | }; 33 | }; 34 | 35 | export default useLoading; 36 | -------------------------------------------------------------------------------- /frontend/web/src/hooks/useNavigateTo.ts: -------------------------------------------------------------------------------- 1 | import { NavigateOptions, useNavigate } from "react-router-dom"; 2 | 3 | const useNavigateTo = () => { 4 | const navigateTo = useNavigate(); 5 | 6 | const navigateToWithViewTransition = (to: string, options?: NavigateOptions) => { 7 | const document = window.document as any; 8 | if (!document.startViewTransition) { 9 | navigateTo(to, options); 10 | } else { 11 | document.startViewTransition(() => { 12 | navigateTo(to, options); 13 | }); 14 | } 15 | }; 16 | 17 | return navigateToWithViewTransition; 18 | }; 19 | 20 | export default useNavigateTo; 21 | -------------------------------------------------------------------------------- /frontend/web/src/hooks/useResponsiveWidth.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import useWindowSize from "react-use/lib/useWindowSize"; 3 | 4 | enum TailwindResponsiveWidth { 5 | sm = 640, 6 | md = 768, 7 | lg = 1024, 8 | } 9 | 10 | const useResponsiveWidth = () => { 11 | const { width } = useWindowSize(); 12 | 13 | return { 14 | sm: width >= TailwindResponsiveWidth.sm, 15 | md: width >= TailwindResponsiveWidth.md, 16 | lg: width >= TailwindResponsiveWidth.lg, 17 | }; 18 | }; 19 | 20 | export default useResponsiveWidth; 21 | -------------------------------------------------------------------------------- /frontend/web/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import en from "../../locales/en.json"; 4 | import fr from "../../locales/fr.json"; 5 | import hu from "../../locales/hu.json"; 6 | import ja from "../../locales/ja.json"; 7 | import ru from "../../locales/ru.json"; 8 | import tr from "../../locales/tr.json"; 9 | import zh from "../../locales/zh.json"; 10 | 11 | i18n.use(initReactI18next).init({ 12 | resources: { 13 | EN: { 14 | translation: en, 15 | }, 16 | ZH: { 17 | translation: zh, 18 | }, 19 | FR: { 20 | translation: fr, 21 | }, 22 | JA: { 23 | translation: ja, 24 | }, 25 | RU: { 26 | translation: ru, 27 | }, 28 | TR: { 29 | translation: tr, 30 | }, 31 | HU: { 32 | translation: hu, 33 | }, 34 | }, 35 | lng: "EN", 36 | fallbackLng: "EN", 37 | }); 38 | 39 | export default i18n; 40 | -------------------------------------------------------------------------------- /frontend/web/src/layouts/CommonContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | import { useUserStore, useWorkspaceStore } from "@/stores"; 3 | 4 | interface Context {} 5 | 6 | const CommonContext = createContext({}); 7 | 8 | const CommonContextProvider = ({ children }: { children: React.ReactNode }) => { 9 | const workspaceStore = useWorkspaceStore(); 10 | const userStore = useUserStore(); 11 | const [commonContext, setCommonContext] = useState({}); 12 | const [initialized, setInitialized] = useState(false); 13 | 14 | useEffect(() => { 15 | (async () => { 16 | try { 17 | await workspaceStore.fetchWorkspaceProfile(); 18 | await workspaceStore.fetchWorkspaceSetting(); 19 | await userStore.fetchCurrentUser(); 20 | } catch (error) { 21 | // Do nothing. 22 | } 23 | setInitialized(true); 24 | })(); 25 | }, []); 26 | 27 | return ( 28 | setCommonContext({ ...commonContext, locale }), 32 | setAppearance: (appearance: string) => setCommonContext({ ...commonContext, appearance }), 33 | }} 34 | > 35 | {!initialized ? null : <>{children}} 36 | 37 | ); 38 | }; 39 | 40 | export const useCommonContext = () => { 41 | return useContext(CommonContext); 42 | }; 43 | 44 | export default CommonContextProvider; 45 | -------------------------------------------------------------------------------- /frontend/web/src/layouts/Root.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from "@mui/joy"; 2 | import { useEffect } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Outlet } from "react-router-dom"; 5 | import Header from "@/components/Header"; 6 | import Navigator from "@/components/Navigator"; 7 | import useNavigateTo from "@/hooks/useNavigateTo"; 8 | import { useUserStore } from "@/stores"; 9 | 10 | const Root: React.FC = () => { 11 | const navigateTo = useNavigateTo(); 12 | const { setMode } = useColorScheme(); 13 | const { i18n } = useTranslation(); 14 | const userStore = useUserStore(); 15 | const currentUser = userStore.getCurrentUser(); 16 | const currentUserSetting = userStore.getCurrentUserSetting(); 17 | const isInitialized = Boolean(currentUser) && Boolean(currentUserSetting); 18 | 19 | useEffect(() => { 20 | if (!currentUser) { 21 | navigateTo("/auth", { 22 | replace: true, 23 | }); 24 | return; 25 | } 26 | 27 | // Prepare user setting. 28 | userStore.fetchUserSetting(currentUser.id); 29 | }, []); 30 | 31 | useEffect(() => { 32 | if (!currentUserSetting) { 33 | return; 34 | } 35 | 36 | i18n.changeLanguage(currentUserSetting.general?.locale || "en"); 37 | 38 | if (currentUserSetting.general?.colorTheme === "LIGHT") { 39 | setMode("light"); 40 | } else if (currentUserSetting.general?.colorTheme === "DARK") { 41 | setMode("dark"); 42 | } else { 43 | setMode("system"); 44 | } 45 | }, [currentUserSetting]); 46 | 47 | return ( 48 | isInitialized && ( 49 |
50 |
51 | 52 | 53 |
54 | ) 55 | ); 56 | }; 57 | 58 | export default Root; 59 | -------------------------------------------------------------------------------- /frontend/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { CssVarsProvider } from "@mui/joy"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Toaster } from "react-hot-toast"; 4 | import { RouterProvider } from "react-router-dom"; 5 | import "./css/index.css"; 6 | import "./css/joy-ui.css"; 7 | import "./i18n"; 8 | import CommonContextProvider from "./layouts/CommonContextProvider"; 9 | import router from "./routers"; 10 | 11 | const container = document.getElementById("root"); 12 | const root = createRoot(container as HTMLElement); 13 | 14 | root.render( 15 | 16 | 17 | 18 | 19 | 20 | , 21 | ); 22 | -------------------------------------------------------------------------------- /frontend/web/src/pages/AdminSignIn.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Logo from "@/components/Logo"; 3 | import PasswordAuthForm from "@/components/PasswordAuthForm"; 4 | 5 | const AdminSignIn: React.FC = () => { 6 | return ( 7 |
8 |
9 |
10 |
11 | 12 | Slash 13 |
14 |

Sign in with admin accounts

15 | 16 |
17 |
18 |
19 | ); 20 | }; 21 | 22 | export default AdminSignIn; 23 | -------------------------------------------------------------------------------- /frontend/web/src/pages/AuthCallback.tsx: -------------------------------------------------------------------------------- 1 | import { ClientError } from "nice-grpc-web"; 2 | import { useEffect, useState } from "react"; 3 | import { useSearchParams } from "react-router-dom"; 4 | import Icon from "@/components/Icon"; 5 | import { authServiceClient } from "@/grpcweb"; 6 | import { absolutifyLink } from "@/helpers/utils"; 7 | import useNavigateTo from "@/hooks/useNavigateTo"; 8 | import { useUserStore } from "@/stores"; 9 | 10 | interface State { 11 | loading: boolean; 12 | errorMessage: string; 13 | } 14 | 15 | const AuthCallback = () => { 16 | const navigateTo = useNavigateTo(); 17 | const [searchParams] = useSearchParams(); 18 | const userStore = useUserStore(); 19 | const [state, setState] = useState({ 20 | loading: true, 21 | errorMessage: "", 22 | }); 23 | 24 | useEffect(() => { 25 | const code = searchParams.get("code"); 26 | const state = searchParams.get("state"); 27 | 28 | if (!code || !state) { 29 | setState({ 30 | loading: false, 31 | errorMessage: "Failed to authorize. Invalid state passed to the auth callback.", 32 | }); 33 | return; 34 | } 35 | 36 | const idpId = state; 37 | if (!idpId) { 38 | setState({ 39 | loading: false, 40 | errorMessage: "No identity provider found in the state parameter.", 41 | }); 42 | return; 43 | } 44 | 45 | const redirectUri = absolutifyLink("/auth/callback"); 46 | (async () => { 47 | try { 48 | await authServiceClient.signInWithSSO({ 49 | idpId, 50 | code, 51 | redirectUri, 52 | }); 53 | setState({ 54 | loading: false, 55 | errorMessage: "", 56 | }); 57 | await userStore.fetchCurrentUser(); 58 | navigateTo("/"); 59 | } catch (error: any) { 60 | console.error(error); 61 | setState({ 62 | loading: false, 63 | errorMessage: (error as ClientError).details, 64 | }); 65 | } 66 | })(); 67 | }, [searchParams]); 68 | 69 | return ( 70 |
71 | {state.loading ? ( 72 | 73 | ) : ( 74 |
{state.errorMessage}
75 | )} 76 |
77 | ); 78 | }; 79 | 80 | export default AuthCallback; 81 | -------------------------------------------------------------------------------- /frontend/web/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import useLocalStorage from "react-use/lib/useLocalStorage"; 3 | import useNavigateTo from "@/hooks/useNavigateTo"; 4 | 5 | const Home: React.FC = () => { 6 | const [lastVisited] = useLocalStorage("lastVisited", "/shortcuts"); 7 | const navigateTo = useNavigateTo(); 8 | 9 | useEffect(() => { 10 | if (lastVisited === "/shortcuts" || lastVisited === "/collections") { 11 | navigateTo(lastVisited); 12 | } else { 13 | navigateTo("/shortcuts"); 14 | } 15 | }, []); 16 | 17 | return <>; 18 | }; 19 | 20 | export default Home; 21 | -------------------------------------------------------------------------------- /frontend/web/src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@/components/Icon"; 2 | 3 | const NotFound = () => { 4 | return ( 5 |
6 |
7 | 8 |

404

9 |
10 |
11 | ); 12 | }; 13 | 14 | export default NotFound; 15 | -------------------------------------------------------------------------------- /frontend/web/src/pages/UserSetting.tsx: -------------------------------------------------------------------------------- 1 | import AccessTokenSection from "@/components/setting/AccessTokenSection"; 2 | import AccountSection from "@/components/setting/AccountSection"; 3 | import PreferenceSection from "@/components/setting/PreferenceSection"; 4 | 5 | const Setting: React.FC = () => { 6 | return ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | }; 14 | 15 | export default Setting; 16 | -------------------------------------------------------------------------------- /frontend/web/src/pages/WorkspaceSetting.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Button, Divider } from "@mui/joy"; 2 | import { useEffect } from "react"; 3 | import { Link } from "react-router-dom"; 4 | import Icon from "@/components/Icon"; 5 | import WorkspaceGeneralSettingSection from "@/components/setting/WorkspaceGeneralSettingSection"; 6 | import WorkspaceMembersSection from "@/components/setting/WorkspaceMembersSection"; 7 | import WorkspaceSecuritySection from "@/components/setting/WorkspaceSecuritySection"; 8 | import { useUserStore, useWorkspaceStore } from "@/stores"; 9 | import { stringifyPlanType } from "@/stores/subscription"; 10 | import { Role } from "@/types/proto/api/v1/user_service"; 11 | 12 | const WorkspaceSetting = () => { 13 | const workspaceStore = useWorkspaceStore(); 14 | const currentUser = useUserStore().getCurrentUser(); 15 | const isAdmin = currentUser.role === Role.ADMIN; 16 | 17 | useEffect(() => { 18 | if (!isAdmin) { 19 | window.location.href = "/"; 20 | } 21 | }, []); 22 | 23 | if (!isAdmin) { 24 | return null; 25 | } 26 | 27 | return ( 28 |
29 | }> 30 | You can see the settings items below because you are an Admin. 31 | 32 |
33 |

Subscription

34 |
35 | Current plan: 36 | {stringifyPlanType(workspaceStore.getSubscription().plan)} 37 | 38 | 41 | 42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | ); 52 | }; 53 | 54 | export default WorkspaceSetting; 55 | -------------------------------------------------------------------------------- /frontend/web/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import useCollectionStore from "./collection"; 2 | import useShortcutStore from "./shortcut"; 3 | import useUserStore from "./user"; 4 | import useViewStore from "./view"; 5 | import useWorkspaceStore from "./workspace"; 6 | 7 | export { useUserStore, useCollectionStore, useShortcutStore, useViewStore, useWorkspaceStore }; 8 | -------------------------------------------------------------------------------- /frontend/web/src/stores/subscription.ts: -------------------------------------------------------------------------------- 1 | import { PlanType } from "@/types/proto/api/v1/subscription_service"; 2 | 3 | export const stringifyPlanType = (planType: PlanType = PlanType.FREE) => { 4 | if (planType === PlanType.FREE) { 5 | return "Free"; 6 | } else if (planType === PlanType.PRO) { 7 | return "Pro"; 8 | } else if (planType === PlanType.ENTERPRISE) { 9 | return "Team"; 10 | } else { 11 | return "Unknown"; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/web/src/stores/workspace.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { workspaceServiceClient } from "@/grpcweb"; 3 | import { Subscription } from "@/types/proto/api/v1/subscription_service"; 4 | import { WorkspaceProfile, WorkspaceSetting } from "@/types/proto/api/v1/workspace_service"; 5 | 6 | export enum FeatureType { 7 | SSO = "ysh.slash.sso", 8 | AdvancedAnalytics = "ysh.slash.advanced-analytics", 9 | UnlimitedAccounts = "ysh.slash.unlimited-accounts", 10 | UnlimitedShortcuts = "ysh.slash.unlimited-shortcuts", 11 | UnlimitedCollections = "ysh.slash.unlimited-collections", 12 | CustomeBranding = "ysh.slash.custom-branding", 13 | } 14 | 15 | interface WorkspaceState { 16 | profile: WorkspaceProfile; 17 | setting: WorkspaceSetting; 18 | 19 | // Workspace related actions. 20 | fetchWorkspaceProfile: () => Promise; 21 | fetchWorkspaceSetting: () => Promise; 22 | getSubscription: () => Subscription; 23 | checkFeatureAvailable: (feature: FeatureType) => boolean; 24 | } 25 | 26 | const useWorkspaceStore = create()((set, get) => ({ 27 | profile: WorkspaceProfile.fromPartial({}), 28 | setting: WorkspaceSetting.fromPartial({}), 29 | fetchWorkspaceProfile: async () => { 30 | const workspaceProfile = await workspaceServiceClient.getWorkspaceProfile({}); 31 | set({ ...get(), profile: workspaceProfile }); 32 | return workspaceProfile; 33 | }, 34 | fetchWorkspaceSetting: async () => { 35 | const workspaceSetting = await workspaceServiceClient.getWorkspaceSetting({}); 36 | set({ ...get(), setting: workspaceSetting }); 37 | return workspaceSetting; 38 | }, 39 | getSubscription: () => Subscription.fromPartial(get().profile.subscription || {}), 40 | checkFeatureAvailable: (feature: FeatureType): boolean => { 41 | return get().profile.subscription?.features.includes(feature) || false; 42 | }, 43 | })); 44 | 45 | export default useWorkspaceStore; 46 | -------------------------------------------------------------------------------- /frontend/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{js,ts,tsx}"], 4 | darkMode: "class", 5 | theme: { 6 | fontSize: { 7 | xs: ".75rem", 8 | sm: ".875rem", 9 | base: "1rem", 10 | lg: "1.125rem", 11 | xl: "1.25rem", 12 | "2xl": "1.5rem", 13 | "3xl": "1.875rem", 14 | "4xl": "2.25rem", 15 | }, 16 | extend: { 17 | maxWidth: { 18 | "8xl": "88rem", 19 | }, 20 | spacing: { 21 | 112: "28rem", 22 | 128: "32rem", 23 | 180: "45rem", 24 | }, 25 | zIndex: { 26 | 1: "1", 27 | 20: "20", 28 | 100: "100", 29 | 1000: "1000", 30 | }, 31 | }, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": ["vite/client"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | }, 21 | "baseUrl": "." 22 | }, 23 | "include": ["./src"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import { resolve } from "path"; 3 | import { defineConfig } from "vite"; 4 | 5 | const devProxyServer = "http://localhost:8082/"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react()], 10 | server: { 11 | host: "0.0.0.0", 12 | port: 3000, 13 | proxy: { 14 | "^/api": { 15 | target: devProxyServer, 16 | xfwd: true, 17 | }, 18 | "^/slash.api.v2": { 19 | target: devProxyServer, 20 | xfwd: true, 21 | }, 22 | }, 23 | }, 24 | resolve: { 25 | alias: { 26 | "@/": `${resolve(__dirname, "src")}/`, 27 | }, 28 | }, 29 | build: { 30 | rollupOptions: { 31 | output: { 32 | entryFileNames: "app.[hash].js", 33 | chunkFileNames: "assets/chunk-vendors.[hash].js", 34 | assetFileNames: "assets/[name].[hash][extname]", 35 | }, 36 | }, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /plugin/httpgetter/html_meta.go: -------------------------------------------------------------------------------- 1 | package httpgetter 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | 9 | "golang.org/x/net/html" 10 | "golang.org/x/net/html/atom" 11 | ) 12 | 13 | type HTMLMeta struct { 14 | Title string `json:"title"` 15 | Description string `json:"description"` 16 | Image string `json:"image"` 17 | } 18 | 19 | func GetHTMLMeta(urlStr string) (*HTMLMeta, error) { 20 | if _, err := url.Parse(urlStr); err != nil { 21 | return nil, err 22 | } 23 | 24 | response, err := http.Get(urlStr) 25 | if err != nil { 26 | return nil, err 27 | } 28 | defer response.Body.Close() 29 | 30 | mediatype, err := getMediatype(response) 31 | if err != nil { 32 | return nil, err 33 | } 34 | if mediatype != "text/html" { 35 | return nil, errors.New("not a HTML page") 36 | } 37 | 38 | htmlMeta := extractHTMLMeta(response.Body) 39 | return htmlMeta, nil 40 | } 41 | 42 | func extractHTMLMeta(resp io.Reader) *HTMLMeta { 43 | tokenizer := html.NewTokenizer(resp) 44 | htmlMeta := new(HTMLMeta) 45 | 46 | for { 47 | tokenType := tokenizer.Next() 48 | if tokenType == html.ErrorToken { 49 | break 50 | } else if tokenType == html.StartTagToken || tokenType == html.SelfClosingTagToken { 51 | token := tokenizer.Token() 52 | if token.DataAtom == atom.Body { 53 | break 54 | } 55 | 56 | if token.DataAtom == atom.Title { 57 | tokenizer.Next() 58 | token := tokenizer.Token() 59 | htmlMeta.Title = token.Data 60 | } else if token.DataAtom == atom.Meta { 61 | description, ok := extractMetaProperty(token, "description") 62 | if ok { 63 | htmlMeta.Description = description 64 | } 65 | 66 | ogTitle, ok := extractMetaProperty(token, "og:title") 67 | if ok { 68 | htmlMeta.Title = ogTitle 69 | } 70 | 71 | ogDescription, ok := extractMetaProperty(token, "og:description") 72 | if ok { 73 | htmlMeta.Description = ogDescription 74 | } 75 | 76 | ogImage, ok := extractMetaProperty(token, "og:image") 77 | if ok { 78 | htmlMeta.Image = ogImage 79 | } 80 | } 81 | } 82 | } 83 | 84 | return htmlMeta 85 | } 86 | 87 | func extractMetaProperty(token html.Token, prop string) (content string, ok bool) { 88 | content, ok = "", false 89 | for _, attr := range token.Attr { 90 | if attr.Key == "property" && attr.Val == prop { 91 | ok = true 92 | } 93 | if attr.Key == "content" { 94 | content = attr.Val 95 | } 96 | } 97 | return content, ok 98 | } 99 | -------------------------------------------------------------------------------- /plugin/httpgetter/html_meta_test.go: -------------------------------------------------------------------------------- 1 | package httpgetter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestGetHTMLMeta(t *testing.T) { 10 | tests := []struct { 11 | urlStr string 12 | htmlMeta HTMLMeta 13 | }{} 14 | for _, test := range tests { 15 | metadata, err := GetHTMLMeta(test.urlStr) 16 | require.NoError(t, err) 17 | require.Equal(t, test.htmlMeta, *metadata) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /plugin/httpgetter/http_getter.go: -------------------------------------------------------------------------------- 1 | // Package httpgetter is using to get resources from url. 2 | // * Get metadata for website; 3 | // * Get image blob to avoid CORS; 4 | package httpgetter 5 | -------------------------------------------------------------------------------- /plugin/httpgetter/image.go: -------------------------------------------------------------------------------- 1 | package httpgetter 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | type Image struct { 12 | Blob []byte 13 | Mediatype string 14 | } 15 | 16 | func GetImage(urlStr string) (*Image, error) { 17 | if _, err := url.Parse(urlStr); err != nil { 18 | return nil, err 19 | } 20 | 21 | response, err := http.Get(urlStr) 22 | if err != nil { 23 | return nil, err 24 | } 25 | defer response.Body.Close() 26 | 27 | mediatype, err := getMediatype(response) 28 | if err != nil { 29 | return nil, err 30 | } 31 | if !strings.HasPrefix(mediatype, "image/") { 32 | return nil, errors.New("wrong image mediatype") 33 | } 34 | 35 | bodyBytes, err := io.ReadAll(response.Body) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | image := &Image{ 41 | Blob: bodyBytes, 42 | Mediatype: mediatype, 43 | } 44 | return image, nil 45 | } 46 | -------------------------------------------------------------------------------- /plugin/httpgetter/util.go: -------------------------------------------------------------------------------- 1 | package httpgetter 2 | 3 | import ( 4 | "mime" 5 | "net/http" 6 | ) 7 | 8 | func getMediatype(response *http.Response) (string, error) { 9 | contentType := response.Header.Get("content-type") 10 | mediatype, _, err := mime.ParseMediaType(contentType) 11 | if err != nil { 12 | return "", err 13 | } 14 | return mediatype, nil 15 | } 16 | -------------------------------------------------------------------------------- /plugin/idp/idp.go: -------------------------------------------------------------------------------- 1 | package idp 2 | 3 | type IdentityProviderUserInfo struct { 4 | Identifier string 5 | Email string 6 | DisplayName string 7 | } 8 | -------------------------------------------------------------------------------- /plugin/mail/login_auth.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "errors" 5 | "net/smtp" 6 | ) 7 | 8 | type loginAuth struct { 9 | username string 10 | password string 11 | } 12 | 13 | // LoginAuth returns an Auth that implements the LOGIN authentication. 14 | func LoginAuth(username, password string) smtp.Auth { 15 | return &loginAuth{username, password} 16 | } 17 | 18 | func (*loginAuth) Start(*smtp.ServerInfo) (string, []byte, error) { 19 | return "LOGIN", []byte{}, nil 20 | } 21 | 22 | func (la *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { 23 | if more { 24 | switch string(fromServer) { 25 | case "Username:": 26 | return []byte(la.username), nil 27 | case "Password:": 28 | return []byte(la.password), nil 29 | default: 30 | return nil, errors.New("unknown fromServer") 31 | } 32 | } 33 | return nil, nil 34 | } 35 | -------------------------------------------------------------------------------- /proto/api/v1/auth_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.api.v1; 4 | 5 | import "api/v1/user_service.proto"; 6 | import "google/api/annotations.proto"; 7 | import "google/protobuf/empty.proto"; 8 | 9 | option go_package = "gen/api/v1"; 10 | 11 | service AuthService { 12 | // GetAuthStatus returns the current auth status of the user. 13 | rpc GetAuthStatus(GetAuthStatusRequest) returns (User) { 14 | option (google.api.http) = {post: "/api/v1/auth/status"}; 15 | } 16 | // SignIn signs in the user with the given username and password. 17 | rpc SignIn(SignInRequest) returns (User) { 18 | option (google.api.http) = {post: "/api/v1/auth/signin"}; 19 | } 20 | // SignInWithSSO signs in the user with the given SSO code. 21 | rpc SignInWithSSO(SignInWithSSORequest) returns (User) { 22 | option (google.api.http) = {post: "/api/v1/auth/signin/sso"}; 23 | } 24 | // SignUp signs up the user with the given username and password. 25 | rpc SignUp(SignUpRequest) returns (User) { 26 | option (google.api.http) = {post: "/api/v1/auth/signup"}; 27 | } 28 | // SignOut signs out the user. 29 | rpc SignOut(SignOutRequest) returns (google.protobuf.Empty) { 30 | option (google.api.http) = {post: "/api/v1/auth/signout"}; 31 | } 32 | } 33 | 34 | message GetAuthStatusRequest {} 35 | 36 | message SignInRequest { 37 | string email = 1; 38 | string password = 2; 39 | } 40 | 41 | message SignUpRequest { 42 | string email = 1; 43 | string nickname = 2; 44 | string password = 3; 45 | } 46 | 47 | message SignInWithSSORequest { 48 | // The id of the SSO provider. 49 | string idp_id = 1; 50 | // The code to sign in with. 51 | string code = 2; 52 | // The redirect URI. 53 | string redirect_uri = 3; 54 | } 55 | 56 | message SignOutRequest {} 57 | -------------------------------------------------------------------------------- /proto/api/v1/common.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.api.v1; 4 | 5 | option go_package = "gen/api/v1"; 6 | 7 | enum State { 8 | STATE_UNSPECIFIED = 0; 9 | ACTIVE = 1; 10 | INACTIVE = 2; 11 | } 12 | 13 | enum Visibility { 14 | VISIBILITY_UNSPECIFIED = 0; 15 | 16 | WORKSPACE = 1; 17 | 18 | PUBLIC = 2; 19 | } 20 | -------------------------------------------------------------------------------- /proto/api/v1/subscription_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.api.v1; 4 | 5 | import "google/api/annotations.proto"; 6 | import "google/api/field_behavior.proto"; 7 | import "google/protobuf/timestamp.proto"; 8 | 9 | option go_package = "gen/api/v1"; 10 | 11 | service SubscriptionService { 12 | // GetSubscription gets the current subscription of Slash instance. 13 | rpc GetSubscription(GetSubscriptionRequest) returns (Subscription) { 14 | option (google.api.http) = {get: "/v1/subscription"}; 15 | } 16 | // UpdateSubscription updates the subscription. 17 | rpc UpdateSubscription(UpdateSubscriptionRequest) returns (Subscription) { 18 | option (google.api.http) = { 19 | patch: "/v1/subscription" 20 | body: "*" 21 | }; 22 | } 23 | // DeleteSubscription deletes the subscription. 24 | rpc DeleteSubscription(DeleteSubscriptionRequest) returns (Subscription) { 25 | option (google.api.http) = {delete: "/v1/subscription"}; 26 | } 27 | } 28 | 29 | message Subscription { 30 | PlanType plan = 1 [(google.api.field_behavior) = OUTPUT_ONLY]; 31 | 32 | google.protobuf.Timestamp started_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; 33 | 34 | google.protobuf.Timestamp expires_time = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; 35 | 36 | repeated string features = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; 37 | 38 | int32 seats = 5 [(google.api.field_behavior) = OUTPUT_ONLY]; 39 | 40 | int32 shortcuts_limit = 6 [(google.api.field_behavior) = OUTPUT_ONLY]; 41 | 42 | int32 collections_limit = 7 [(google.api.field_behavior) = OUTPUT_ONLY]; 43 | } 44 | 45 | enum PlanType { 46 | PLAN_TYPE_UNSPECIFIED = 0; 47 | FREE = 1; 48 | PRO = 2; 49 | ENTERPRISE = 3; 50 | } 51 | 52 | message GetSubscriptionRequest {} 53 | 54 | message UpdateSubscriptionRequest { 55 | string license_key = 1 [(google.api.field_behavior) = REQUIRED]; 56 | } 57 | 58 | message DeleteSubscriptionRequest {} 59 | -------------------------------------------------------------------------------- /proto/api/v1/user_setting_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.api.v1; 4 | 5 | import "google/api/annotations.proto"; 6 | import "google/api/client.proto"; 7 | import "google/protobuf/field_mask.proto"; 8 | 9 | option go_package = "gen/api/v1"; 10 | 11 | service UserSettingService { 12 | // GetUserSetting returns the user setting. 13 | rpc GetUserSetting(GetUserSettingRequest) returns (UserSetting) { 14 | option (google.api.http) = {get: "/api/v1/users/{id}/settings"}; 15 | option (google.api.method_signature) = "id"; 16 | } 17 | // UpdateUserSetting updates the user setting. 18 | rpc UpdateUserSetting(UpdateUserSettingRequest) returns (UserSetting) { 19 | option (google.api.http) = { 20 | patch: "/api/v1/users/{id}/settings" 21 | body: "user_setting" 22 | }; 23 | option (google.api.method_signature) = "user_setting,update_mask"; 24 | } 25 | } 26 | 27 | message UserSetting { 28 | int32 user_id = 1; 29 | 30 | GeneralSetting general = 2; 31 | 32 | AccessTokensSetting access_tokens = 3; 33 | 34 | message GeneralSetting { 35 | string locale = 1; 36 | string color_theme = 2; 37 | } 38 | 39 | message AccessTokensSetting { 40 | message AccessToken { 41 | // The access token is a JWT token, including expiration time, issuer, etc. 42 | string access_token = 1; 43 | // A description for the access token. 44 | string description = 2; 45 | } 46 | repeated AccessToken access_tokens = 1; // Nested repeated field 47 | } 48 | } 49 | 50 | message GetUserSettingRequest { 51 | // id is the user id. 52 | int32 id = 1; 53 | } 54 | 55 | message UpdateUserSettingRequest { 56 | // id is the user id. 57 | int32 id = 1; 58 | 59 | // user_setting is the user setting to update. 60 | UserSetting user_setting = 2; 61 | 62 | // update_mask is the field mask to update. 63 | google.protobuf.FieldMask update_mask = 3; 64 | } 65 | -------------------------------------------------------------------------------- /proto/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | disable: 5 | - file_option: go_package 6 | module: buf.build/googleapis/googleapis 7 | override: 8 | - file_option: go_package_prefix 9 | value: github.com/yourselfhosted/slash/proto/gen 10 | plugins: 11 | - remote: buf.build/protocolbuffers/go 12 | out: gen 13 | opt: 14 | - paths=source_relative 15 | - remote: buf.build/grpc/go 16 | out: gen 17 | opt: 18 | - paths=source_relative 19 | - remote: buf.build/grpc-ecosystem/gateway 20 | out: gen 21 | opt: 22 | - paths=source_relative 23 | # Generate types for the web client. 24 | - remote: buf.build/community/stephenh-ts-proto 25 | out: ../frontend/web/src/types/proto 26 | # reference: https://github.com/deeplay-io/nice-grpc/blob/master/packages/nice-grpc-web/README.md#using-ts-proto 27 | opt: 28 | - env=browser 29 | - useOptionals=messages 30 | - outputServices=generic-definitions 31 | - outputJsonMethods=false 32 | - useExactTypes=false 33 | - esModuleInterop=true 34 | - stringEnums=true 35 | - remote: buf.build/community/pseudomuto-doc 36 | out: gen 37 | opt: 38 | - markdown,README.md,source_relative 39 | - remote: buf.build/grpc-ecosystem/openapiv2 40 | out: gen 41 | opt: output_format=yaml,allow_merge=true 42 | -------------------------------------------------------------------------------- /proto/buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v2 3 | deps: 4 | - name: buf.build/googleapis/googleapis 5 | commit: a86849a25cc04f4dbe9b15ddddfbc488 6 | digest: b5:a77a2082c596ee6800a23d8cecd021d316eb10565d6cb94532f2d7c567fe6c9a177b5bb123b51a3acb4f1f18d4f54a6da883afcb682919a137a8a37c020509a2 7 | - name: buf.build/grpc-ecosystem/grpc-gateway 8 | commit: 3f42134f4c564983838425bc43c7a65f 9 | digest: b5:291b947d8ac09492517557e4e72e294788cb8201afc7d0df7bda80fa10931adb60d4d669208a7696bf24f1ecb2a33a16d4c1e766e6f31809248b00343119569b 10 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | name: buf.build/yourselfhosted/slash 3 | deps: 4 | - buf.build/googleapis/googleapis 5 | - buf.build/grpc-ecosystem/grpc-gateway 6 | lint: 7 | use: 8 | - BASIC 9 | except: 10 | - ENUM_VALUE_PREFIX 11 | - PACKAGE_DIRECTORY_MATCH 12 | - PACKAGE_VERSION_SUFFIX 13 | breaking: 14 | use: 15 | - FILE 16 | -------------------------------------------------------------------------------- /proto/store/activity.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.store; 4 | 5 | option go_package = "gen/store"; 6 | 7 | message ActivityShorcutCreatePayload { 8 | int32 shortcut_id = 1; 9 | } 10 | 11 | message ActivityShorcutViewPayload { 12 | int32 shortcut_id = 1; 13 | string ip = 2; 14 | string referer = 3; 15 | string user_agent = 4; 16 | map params = 5; 17 | 18 | message ValueList { 19 | repeated string values = 1; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /proto/store/collection.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.store; 4 | 5 | import "store/common.proto"; 6 | 7 | option go_package = "gen/store"; 8 | 9 | message Collection { 10 | int32 id = 1; 11 | 12 | int32 creator_id = 2; 13 | 14 | int64 created_ts = 3; 15 | 16 | int64 updated_ts = 4; 17 | 18 | string name = 6; 19 | 20 | string title = 7; 21 | 22 | string description = 8; 23 | 24 | repeated int32 shortcut_ids = 9; 25 | 26 | Visibility visibility = 10; 27 | } 28 | -------------------------------------------------------------------------------- /proto/store/common.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.store; 4 | 5 | option go_package = "gen/store"; 6 | 7 | enum RowStatus { 8 | ROW_STATUS_UNSPECIFIED = 0; 9 | 10 | NORMAL = 1; 11 | 12 | ARCHIVED = 2; 13 | } 14 | 15 | enum Visibility { 16 | VISIBILITY_UNSPECIFIED = 0; 17 | 18 | WORKSPACE = 1; 19 | 20 | PUBLIC = 2; 21 | } 22 | -------------------------------------------------------------------------------- /proto/store/idp.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.store; 4 | 5 | option go_package = "gen/store"; 6 | 7 | message IdentityProvider { 8 | // The unique identifier of the identity provider. 9 | string id = 1; 10 | string title = 2; 11 | 12 | enum Type { 13 | TYPE_UNSPECIFIED = 0; 14 | OAUTH2 = 1; 15 | } 16 | Type type = 3; 17 | IdentityProviderConfig config = 4; 18 | } 19 | 20 | message IdentityProviderConfig { 21 | oneof config { 22 | OAuth2Config oauth2 = 1; 23 | } 24 | 25 | message FieldMapping { 26 | string identifier = 1; 27 | string display_name = 2; 28 | } 29 | 30 | message OAuth2Config { 31 | string client_id = 1; 32 | string client_secret = 2; 33 | string auth_url = 3; 34 | string token_url = 4; 35 | string user_info_url = 5; 36 | repeated string scopes = 6; 37 | FieldMapping field_mapping = 7; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /proto/store/shortcut.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.store; 4 | 5 | import "store/common.proto"; 6 | 7 | option go_package = "gen/store"; 8 | 9 | message Shortcut { 10 | int32 id = 1; 11 | 12 | int32 creator_id = 2; 13 | 14 | int64 created_ts = 3; 15 | 16 | int64 updated_ts = 4; 17 | 18 | string name = 6; 19 | 20 | string link = 7; 21 | 22 | string title = 8; 23 | 24 | repeated string tags = 9; 25 | 26 | string description = 10; 27 | 28 | Visibility visibility = 11; 29 | 30 | OpenGraphMetadata og_metadata = 12; 31 | } 32 | 33 | message OpenGraphMetadata { 34 | string title = 1; 35 | 36 | string description = 2; 37 | 38 | string image = 3; 39 | } 40 | -------------------------------------------------------------------------------- /proto/store/user_setting.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.store; 4 | 5 | option go_package = "gen/store"; 6 | 7 | message UserSetting { 8 | int32 user_id = 1; 9 | 10 | UserSettingKey key = 2; 11 | 12 | oneof value { 13 | GeneralSetting general = 3; 14 | AccessTokensSetting access_tokens = 4; 15 | } 16 | 17 | message GeneralSetting { 18 | string locale = 1; 19 | string color_theme = 2; 20 | } 21 | 22 | message AccessTokensSetting { 23 | message AccessToken { 24 | // The access token is a JWT token, including expiration time, issuer, etc. 25 | string access_token = 1; 26 | // A description for the access token. 27 | string description = 2; 28 | } 29 | repeated AccessToken access_tokens = 1; // Nested repeated field 30 | } 31 | } 32 | 33 | enum UserSettingKey { 34 | USER_SETTING_KEY_UNSPECIFIED = 0; 35 | // User general settings. 36 | USER_SETTING_GENERAL = 1; 37 | // User access tokens. 38 | USER_SETTING_ACCESS_TOKENS = 2; 39 | } 40 | -------------------------------------------------------------------------------- /proto/store/workspace_setting.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package slash.store; 4 | 5 | import "store/common.proto"; 6 | import "store/idp.proto"; 7 | 8 | option go_package = "gen/store"; 9 | 10 | message WorkspaceSetting { 11 | WorkspaceSettingKey key = 1; 12 | string raw = 2; 13 | 14 | oneof value { 15 | GeneralSetting general = 3; 16 | SecuritySetting security = 4; 17 | ShortcutRelatedSetting shortcut_related = 5; 18 | IdentityProviderSetting identity_provider = 6; 19 | } 20 | 21 | message GeneralSetting { 22 | string secret_session = 1; 23 | string license_key = 2; 24 | string instance_url = 3; 25 | bytes branding = 4; 26 | string custom_style = 5; 27 | } 28 | 29 | message SecuritySetting { 30 | bool disallow_user_registration = 1; 31 | bool disallow_password_auth = 2; 32 | } 33 | 34 | message ShortcutRelatedSetting { 35 | Visibility default_visibility = 1; 36 | } 37 | 38 | message IdentityProviderSetting { 39 | repeated IdentityProvider identity_providers = 1; 40 | } 41 | } 42 | 43 | enum WorkspaceSettingKey { 44 | WORKSPACE_SETTING_KEY_UNSPECIFIED = 0; 45 | // Workspace general settings. 46 | WORKSPACE_SETTING_GENERAL = 1; 47 | // Workspace security settings. 48 | WORKSPACE_SETTING_SECURITY = 2; 49 | // Workspace shortcut-related settings. 50 | WORKSPACE_SETTING_SHORTCUT_RELATED = 3; 51 | // Workspace identity provider settings. 52 | WORKSPACE_SETTING_IDENTITY_PROVIDER = 4; 53 | 54 | // TODO: remove the following keys. 55 | // The license key. 56 | WORKSPACE_SETTING_LICENSE_KEY = 10; 57 | // The secret session key used to encrypt session data. 58 | WORKSPACE_SETTING_SECRET_SESSION = 11; 59 | // The custom style. 60 | WORKSPACE_SETTING_CUSTOM_STYLE = 12; 61 | // The default visibility of shortcuts and collections. 62 | WORKSPACE_SETTING_DEFAULT_VISIBILITY = 13; 63 | } 64 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit when any command fails 4 | set -e 5 | 6 | # Get the script directory and change to the project root 7 | cd "$(dirname "$0")/../" 8 | 9 | # Detect the operating system 10 | OS=$(uname -s) 11 | 12 | # Set output file name based on the OS 13 | if [[ "$OS" == *"CYGWIN"* || "$OS" == *"MINGW"* || "$OS" == *"MSYS"* ]]; then 14 | OUTPUT="./build/slash.exe" 15 | else 16 | OUTPUT="./build/slash" 17 | fi 18 | 19 | echo "Building for $OS..." 20 | 21 | # Build the executable 22 | go build -o "$OUTPUT" ./bin/slash/main.go 23 | 24 | # Output the success message 25 | echo "Build successful!" 26 | 27 | # Output the command to run 28 | echo "To run the application, execute the following command:" 29 | echo "$OUTPUT --mode dev" 30 | -------------------------------------------------------------------------------- /server/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | const ( 4 | // BotID is the id of bot. 5 | BotID = 0 6 | ) 7 | -------------------------------------------------------------------------------- /server/common/version.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "golang.org/x/mod/semver" 8 | ) 9 | 10 | // Version is the service current released version. 11 | // Semantic versioning: https://semver.org/ 12 | var Version = "1.0.0" 13 | 14 | // DevVersion is the service current development version. 15 | var DevVersion = "1.0.0" 16 | 17 | func GetCurrentVersion(mode string) string { 18 | if mode == "dev" || mode == "demo" { 19 | return DevVersion 20 | } 21 | return Version 22 | } 23 | 24 | func GetMinorVersion(version string) string { 25 | versionList := strings.Split(version, ".") 26 | if len(versionList) < 3 { 27 | return "" 28 | } 29 | return versionList[0] + "." + versionList[1] 30 | } 31 | 32 | func GetSchemaVersion(version string) string { 33 | minorVersion := GetMinorVersion(version) 34 | return minorVersion + ".0" 35 | } 36 | 37 | // IsVersionGreaterOrEqualThan returns true if version is greater than or equal to target. 38 | func IsVersionGreaterOrEqualThan(version, target string) bool { 39 | return semver.Compare(fmt.Sprintf("v%s", version), fmt.Sprintf("v%s", target)) > -1 40 | } 41 | 42 | // IsVersionGreaterThan returns true if version is greater than target. 43 | func IsVersionGreaterThan(version, target string) bool { 44 | return semver.Compare(fmt.Sprintf("v%s", version), fmt.Sprintf("v%s", target)) > 0 45 | } 46 | 47 | type SortVersion []string 48 | 49 | func (s SortVersion) Len() int { 50 | return len(s) 51 | } 52 | 53 | func (s SortVersion) Swap(i, j int) { 54 | s[i], s[j] = s[j], s[i] 55 | } 56 | 57 | func (s SortVersion) Less(i, j int) bool { 58 | v1 := fmt.Sprintf("v%s", s[i]) 59 | v2 := fmt.Sprintf("v%s", s[j]) 60 | return semver.Compare(v1, v2) == -1 61 | } 62 | -------------------------------------------------------------------------------- /server/common/version_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestIsVersionGreaterOrEqualThan(t *testing.T) { 11 | tests := []struct { 12 | version string 13 | target string 14 | want bool 15 | }{ 16 | { 17 | version: "0.9.1", 18 | target: "0.9.1", 19 | want: true, 20 | }, 21 | { 22 | version: "0.10.0", 23 | target: "0.9.1", 24 | want: true, 25 | }, 26 | { 27 | version: "0.9.0", 28 | target: "0.9.1", 29 | want: false, 30 | }, 31 | } 32 | for _, test := range tests { 33 | result := IsVersionGreaterOrEqualThan(test.version, test.target) 34 | if result != test.want { 35 | t.Errorf("got result %v, want %v.", result, test.want) 36 | } 37 | } 38 | } 39 | 40 | func TestIsVersionGreaterThan(t *testing.T) { 41 | tests := []struct { 42 | version string 43 | target string 44 | want bool 45 | }{ 46 | { 47 | version: "0.9.1", 48 | target: "0.9.1", 49 | want: false, 50 | }, 51 | { 52 | version: "0.10.0", 53 | target: "0.8.0", 54 | want: true, 55 | }, 56 | { 57 | version: "0.8.0", 58 | target: "0.10.0", 59 | want: false, 60 | }, 61 | { 62 | version: "0.9.0", 63 | target: "0.9.1", 64 | want: false, 65 | }, 66 | } 67 | for _, test := range tests { 68 | result := IsVersionGreaterThan(test.version, test.target) 69 | if result != test.want { 70 | t.Errorf("got result %v, want %v.", result, test.want) 71 | } 72 | } 73 | } 74 | 75 | func TestSortVersion(t *testing.T) { 76 | tests := []struct { 77 | versionList []string 78 | want []string 79 | }{ 80 | { 81 | versionList: []string{"0.9.1", "0.10.0", "0.8.0"}, 82 | want: []string{"0.8.0", "0.9.1", "0.10.0"}, 83 | }, 84 | { 85 | versionList: []string{"1.9.1", "0.9.1", "0.10.0", "0.8.0"}, 86 | want: []string{"0.8.0", "0.9.1", "0.10.0", "1.9.1"}, 87 | }, 88 | } 89 | for _, test := range tests { 90 | sort.Sort(SortVersion(test.versionList)) 91 | assert.Equal(t, test.versionList, test.want) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /server/profile/profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // Profile is the configuration to start main server. 14 | type Profile struct { 15 | // Mode can be "prod" or "dev". 16 | Mode string 17 | // Port is the binding port for server. 18 | Port int 19 | // Data is the data directory. 20 | Data string 21 | // DSN points to where slash stores its own data. 22 | DSN string 23 | // Driver is the database driver. Supported drivers are sqlite, postgres. 24 | Driver string 25 | // Version is the current version of server. 26 | Version string 27 | } 28 | 29 | func (p *Profile) IsDev() bool { 30 | return p.Mode != "prod" 31 | } 32 | 33 | func checkDataDir(dataDir string) (string, error) { 34 | // Convert to absolute path if relative path is supplied. 35 | if !filepath.IsAbs(dataDir) { 36 | relativeDir := filepath.Join(filepath.Dir(os.Args[0]), dataDir) 37 | absDir, err := filepath.Abs(relativeDir) 38 | if err != nil { 39 | return "", err 40 | } 41 | dataDir = absDir 42 | } 43 | 44 | // Trim trailing \ or / in case user supplies 45 | dataDir = strings.TrimRight(dataDir, "\\/") 46 | 47 | if _, err := os.Stat(dataDir); err != nil { 48 | return "", errors.Wrapf(err, "unable to access data folder %s", dataDir) 49 | } 50 | 51 | return dataDir, nil 52 | } 53 | 54 | func (p *Profile) Validate() error { 55 | if p.Mode != "demo" && p.Mode != "dev" && p.Mode != "prod" { 56 | p.Mode = "demo" 57 | } 58 | 59 | if p.Mode == "prod" && p.Data == "" { 60 | if runtime.GOOS == "windows" { 61 | p.Data = filepath.Join(os.Getenv("ProgramData"), "slash") 62 | if _, err := os.Stat(p.Data); os.IsNotExist(err) { 63 | if err := os.MkdirAll(p.Data, 0770); err != nil { 64 | fmt.Printf("Failed to create data directory: %s, err: %+v\n", p.Data, err) 65 | return err 66 | } 67 | } 68 | } else { 69 | p.Data = "/var/opt/slash" 70 | } 71 | } 72 | 73 | dataDir, err := checkDataDir(p.Data) 74 | if err != nil { 75 | fmt.Printf("Failed to check dsn: %s, err: %+v\n", dataDir, err) 76 | return err 77 | } 78 | 79 | p.Data = dataDir 80 | if p.Driver == "sqlite" && p.DSN == "" { 81 | dbFile := fmt.Sprintf("slash_%s.db", p.Mode) 82 | p.DSN = filepath.Join(dataDir, dbFile) 83 | } 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /server/route/api/v1/acl_config.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import "strings" 4 | 5 | var allowedMethodsWhenUnauthorized = map[string]bool{ 6 | "/slash.api.v1.WorkspaceService/GetWorkspaceProfile": true, 7 | "/slash.api.v1.WorkspaceService/GetWorkspaceSetting": true, 8 | "/slash.api.v1.AuthService/GetAuthStatus": true, 9 | "/slash.api.v1.AuthService/SignIn": true, 10 | "/slash.api.v1.AuthService/SignInWithSSO": true, 11 | "/slash.api.v1.AuthService/SignUp": true, 12 | "/slash.api.v1.AuthService/SignOut": true, 13 | "/slash.api.v1.ShortcutService/GetShortcut": true, 14 | "/slash.api.v1.ShortcutService/GetShortcutByName": true, 15 | "/slash.api.v1.CollectionService/GetCollectionByName": true, 16 | } 17 | 18 | // isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized. 19 | func isUnauthorizeAllowedMethod(methodName string) bool { 20 | if strings.HasPrefix(methodName, "/grpc.reflection") { 21 | return true 22 | } 23 | return allowedMethodsWhenUnauthorized[methodName] 24 | } 25 | 26 | var allowedMethodsOnlyForAdmin = map[string]bool{ 27 | "/slash.api.v1.UserService/CreateUser": true, 28 | "/slash.api.v1.UserService/DeleteUser": true, 29 | "/slash.api.v1.WorkspaceService/UpdateWorkspaceSetting": true, 30 | "/slash.api.v1.SubscriptionService/UpdateSubscription": true, 31 | } 32 | 33 | // isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin. 34 | func isOnlyForAdminAllowedMethod(methodName string) bool { 35 | return allowedMethodsOnlyForAdmin[methodName] 36 | } 37 | -------------------------------------------------------------------------------- /server/route/api/v1/auth.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/golang-jwt/jwt/v5" 8 | ) 9 | 10 | const ( 11 | // issuer is the issuer of the jwt token. 12 | Issuer = "slash" 13 | // Signing key section. For now, this is only used for signing, not for verifying since we only 14 | // have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism. 15 | KeyID = "v1" 16 | // AccessTokenAudienceName is the audience name of the access token. 17 | AccessTokenAudienceName = "user.access-token" 18 | AccessTokenDuration = 7 * 24 * time.Hour 19 | 20 | // CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user 21 | // cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt. 22 | CookieExpDuration = AccessTokenDuration - 1*time.Minute 23 | // AccessTokenCookieName is the cookie name of access token. 24 | AccessTokenCookieName = "slash.access-token" 25 | ) 26 | 27 | type ClaimsMessage struct { 28 | Name string `json:"name"` 29 | jwt.RegisteredClaims 30 | } 31 | 32 | // GenerateAccessToken generates an access token. 33 | // username is the email of the user. 34 | func GenerateAccessToken(username string, userID int32, expirationTime time.Time, secret []byte) (string, error) { 35 | return generateToken(username, userID, AccessTokenAudienceName, expirationTime, secret) 36 | } 37 | 38 | // generateToken generates a jwt token. 39 | func generateToken(username string, userID int32, audience string, expirationTime time.Time, secret []byte) (string, error) { 40 | registeredClaims := jwt.RegisteredClaims{ 41 | Issuer: Issuer, 42 | Audience: jwt.ClaimStrings{audience}, 43 | IssuedAt: jwt.NewNumericDate(time.Now()), 44 | Subject: fmt.Sprint(userID), 45 | } 46 | if !expirationTime.IsZero() { 47 | registeredClaims.ExpiresAt = jwt.NewNumericDate(expirationTime) 48 | } 49 | 50 | // Declare the token with the HS256 algorithm used for signing, and the claims. 51 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ClaimsMessage{ 52 | Name: username, 53 | RegisteredClaims: registeredClaims, 54 | }) 55 | token.Header["kid"] = KeyID 56 | 57 | // Create the JWT string. 58 | tokenString, err := token.SignedString(secret) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | return tokenString, nil 64 | } 65 | -------------------------------------------------------------------------------- /server/route/api/v1/common.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | 6 | v1pb "github.com/yourselfhosted/slash/proto/gen/api/v1" 7 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 8 | "github.com/yourselfhosted/slash/store" 9 | ) 10 | 11 | func getCurrentUser(ctx context.Context, s *store.Store) (*store.User, error) { 12 | userID, ok := ctx.Value(userIDContextKey).(int32) 13 | if !ok { 14 | return nil, nil 15 | } 16 | user, err := s.GetUser(ctx, &store.FindUser{ 17 | ID: &userID, 18 | }) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return user, nil 23 | } 24 | 25 | func convertStateFromRowStatus(rowStatus storepb.RowStatus) v1pb.State { 26 | switch rowStatus { 27 | case storepb.RowStatus_NORMAL: 28 | return v1pb.State_ACTIVE 29 | case storepb.RowStatus_ARCHIVED: 30 | return v1pb.State_INACTIVE 31 | default: 32 | return v1pb.State_STATE_UNSPECIFIED 33 | } 34 | } 35 | 36 | // ConvertStateToRowStatus converts a v1pb.State to a storepb.RowStatus. 37 | func ConvertStateToRowStatus(state v1pb.State) storepb.RowStatus { 38 | switch state { 39 | case v1pb.State_ACTIVE: 40 | return storepb.RowStatus_NORMAL 41 | case v1pb.State_INACTIVE: 42 | return storepb.RowStatus_ARCHIVED 43 | default: 44 | return storepb.RowStatus_ROW_STATUS_UNSPECIFIED 45 | } 46 | } 47 | 48 | func convertVisibilityFromStorepb(visibility storepb.Visibility) v1pb.Visibility { 49 | switch visibility { 50 | case storepb.Visibility_WORKSPACE: 51 | return v1pb.Visibility_WORKSPACE 52 | case storepb.Visibility_PUBLIC: 53 | return v1pb.Visibility_PUBLIC 54 | default: 55 | return v1pb.Visibility_VISIBILITY_UNSPECIFIED 56 | } 57 | } 58 | 59 | func convertVisibilityToStorepb(visibility v1pb.Visibility) storepb.Visibility { 60 | switch visibility { 61 | case v1pb.Visibility_WORKSPACE: 62 | return storepb.Visibility_WORKSPACE 63 | case v1pb.Visibility_PUBLIC: 64 | return storepb.Visibility_PUBLIC 65 | default: 66 | return storepb.Visibility_VISIBILITY_UNSPECIFIED 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /server/route/api/v1/logger_interceptor.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | ) 11 | 12 | type LoggerInterceptor struct { 13 | } 14 | 15 | func NewLoggerInterceptor() *LoggerInterceptor { 16 | return &LoggerInterceptor{} 17 | } 18 | 19 | func (in *LoggerInterceptor) LoggerInterceptor(ctx context.Context, request any, serverInfo *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { 20 | resp, err := handler(ctx, request) 21 | in.loggerInterceptorDo(ctx, serverInfo.FullMethod, err) 22 | return resp, err 23 | } 24 | 25 | func (*LoggerInterceptor) loggerInterceptorDo(ctx context.Context, fullMethod string, err error) { 26 | st := status.Convert(err) 27 | var logLevel slog.Level 28 | var logMsg string 29 | switch st.Code() { 30 | case codes.OK: 31 | logLevel = slog.LevelInfo 32 | logMsg = "OK" 33 | case codes.Unauthenticated, codes.OutOfRange, codes.PermissionDenied, codes.NotFound: 34 | logLevel = slog.LevelInfo 35 | logMsg = "client error" 36 | case codes.Internal, codes.Unknown, codes.DataLoss, codes.Unavailable, codes.DeadlineExceeded: 37 | logLevel = slog.LevelError 38 | logMsg = "server error" 39 | default: 40 | logLevel = slog.LevelError 41 | logMsg = "unknown error" 42 | } 43 | logAttrs := []slog.Attr{slog.String("method", fullMethod)} 44 | if err != nil { 45 | logAttrs = append(logAttrs, slog.String("error", err.Error())) 46 | } 47 | slog.LogAttrs(ctx, logLevel, logMsg, logAttrs...) 48 | } 49 | -------------------------------------------------------------------------------- /server/route/api/v1/resource_name.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | const ( 4 | UserNamePrefix = "users/" 5 | ) 6 | -------------------------------------------------------------------------------- /server/route/api/v1/subscription_service.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/status" 8 | 9 | v1pb "github.com/yourselfhosted/slash/proto/gen/api/v1" 10 | ) 11 | 12 | func (s *APIV1Service) GetSubscription(ctx context.Context, _ *v1pb.GetSubscriptionRequest) (*v1pb.Subscription, error) { 13 | subscription, err := s.LicenseService.LoadSubscription(ctx) 14 | if err != nil { 15 | return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err) 16 | } 17 | return subscription, nil 18 | } 19 | 20 | func (s *APIV1Service) UpdateSubscription(ctx context.Context, request *v1pb.UpdateSubscriptionRequest) (*v1pb.Subscription, error) { 21 | subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey) 22 | if err != nil { 23 | return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err) 24 | } 25 | return subscription, nil 26 | } 27 | 28 | func (s *APIV1Service) DeleteSubscription(ctx context.Context, _ *v1pb.DeleteSubscriptionRequest) (*v1pb.Subscription, error) { 29 | subscription, err := s.LicenseService.UpdateSubscription(ctx, "") 30 | if err != nil { 31 | return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err) 32 | } 33 | return subscription, nil 34 | } 35 | -------------------------------------------------------------------------------- /server/route/frontend/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Slash 7 | 8 | 9 | No embeddable frontend found. 10 | 11 | 12 | -------------------------------------------------------------------------------- /server/runner/license/runner.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/yourselfhosted/slash/server/service/license" 9 | "github.com/yourselfhosted/slash/store" 10 | ) 11 | 12 | type Runner struct { 13 | Store *store.Store 14 | 15 | licenseService *license.LicenseService 16 | } 17 | 18 | func NewRunner(store *store.Store, licenseService *license.LicenseService) *Runner { 19 | return &Runner{ 20 | Store: store, 21 | licenseService: licenseService, 22 | } 23 | } 24 | 25 | // Schedule runner every 12 hours. 26 | const runnerInterval = time.Hour * 12 27 | 28 | func (r *Runner) Run(ctx context.Context) { 29 | ticker := time.NewTicker(runnerInterval) 30 | defer ticker.Stop() 31 | 32 | for { 33 | select { 34 | case <-ticker.C: 35 | r.RunOnce(ctx) 36 | case <-ctx.Done(): 37 | return 38 | } 39 | } 40 | } 41 | 42 | func (r *Runner) RunOnce(ctx context.Context) { 43 | // Load subscription. 44 | if _, err := r.licenseService.LoadSubscription(ctx); err != nil { 45 | slog.Error("failed to load subscription", slog.Any("error", err)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/runner/version/runner.go: -------------------------------------------------------------------------------- 1 | // Package version provides a runner to check the latest version of the application. 2 | package version 3 | 4 | import ( 5 | "context" 6 | "time" 7 | 8 | "github.com/yourselfhosted/slash/server/profile" 9 | "github.com/yourselfhosted/slash/store" 10 | ) 11 | 12 | type Runner struct { 13 | Store *store.Store 14 | Profile *profile.Profile 15 | } 16 | 17 | func NewRunner(store *store.Store, profile *profile.Profile) *Runner { 18 | return &Runner{ 19 | Store: store, 20 | Profile: profile, 21 | } 22 | } 23 | 24 | // Schedule checker every 8 hours. 25 | const runnerInterval = time.Hour * 8 26 | 27 | func (r *Runner) Run(ctx context.Context) { 28 | ticker := time.NewTicker(runnerInterval) 29 | defer ticker.Stop() 30 | 31 | for { 32 | select { 33 | case <-ticker.C: 34 | r.RunOnce(ctx) 35 | case <-ctx.Done(): 36 | return 37 | } 38 | } 39 | } 40 | 41 | func (*Runner) RunOnce(_ context.Context) { 42 | // Implement me. 43 | } 44 | -------------------------------------------------------------------------------- /server/service/license/feature_matrix.go: -------------------------------------------------------------------------------- 1 | package license 2 | 3 | import ( 4 | v1pb "github.com/yourselfhosted/slash/proto/gen/api/v1" 5 | ) 6 | 7 | type FeatureType string 8 | 9 | const ( 10 | // Enterprise features. 11 | 12 | // FeatureTypeSSO allows the user to use SSO. 13 | FeatureTypeSSO FeatureType = "ysh.slash.sso" 14 | // FeatureTypeAdvancedAnalytics allows the user to use advanced analytics. 15 | FeatureTypeAdvancedAnalytics FeatureType = "ysh.slash.advanced-analytics" 16 | 17 | // Usages. 18 | 19 | // FeatureTypeUnlimitedAccounts allows the user to create unlimited accounts. 20 | FeatureTypeUnlimitedAccounts FeatureType = "ysh.slash.unlimited-accounts" 21 | // FeatureTypeUnlimitedShortcuts allows the user to create unlimited shortcuts. 22 | FeatureTypeUnlimitedShortcuts FeatureType = "ysh.slash.unlimited-shortcuts" 23 | // FeatureTypeUnlimitedAccounts allows the user to create unlimited collections. 24 | FeatureTypeUnlimitedCollections FeatureType = "ysh.slash.unlimited-collections" 25 | 26 | // Customization. 27 | 28 | // FeatureTypeCustomeBranding allows the user to customize the branding. 29 | FeatureTypeCustomeBranding FeatureType = "ysh.slash.custom-branding" 30 | ) 31 | 32 | func (f FeatureType) String() string { 33 | return string(f) 34 | } 35 | 36 | // FeatureMatrix is a matrix of features in [Free, Pro, Enterprise]. 37 | var FeatureMatrix = map[FeatureType][3]bool{ 38 | FeatureTypeUnlimitedAccounts: {false, true, true}, 39 | FeatureTypeUnlimitedShortcuts: {false, true, true}, 40 | FeatureTypeUnlimitedCollections: {false, true, true}, 41 | FeatureTypeCustomeBranding: {false, false, true}, 42 | FeatureTypeSSO: {false, false, false}, 43 | FeatureTypeAdvancedAnalytics: {false, false, false}, 44 | } 45 | 46 | func getDefaultFeatures(plan v1pb.PlanType) []FeatureType { 47 | var features []FeatureType 48 | for feature, enabled := range FeatureMatrix { 49 | if enabled[plan-1] { 50 | features = append(features, feature) 51 | } 52 | } 53 | return features 54 | } 55 | 56 | func validateFeatureString(feature string) (FeatureType, bool) { 57 | switch feature { 58 | case "ysh.slash.unlimited-accounts": 59 | return FeatureTypeUnlimitedAccounts, true 60 | case "ysh.slash.unlimited-shortcuts": 61 | return FeatureTypeUnlimitedShortcuts, true 62 | case "ysh.slash.unlimited-collections": 63 | return FeatureTypeUnlimitedCollections, true 64 | case "ysh.slash.custom-branding": 65 | return FeatureTypeCustomeBranding, true 66 | case "ysh.slash.sso": 67 | return FeatureTypeSSO, true 68 | case "ysh.slash.advanced-analytics": 69 | return FeatureTypeAdvancedAnalytics, true 70 | default: 71 | return "", false 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/service/license/lemonsqueezy/lemonsqueezy.go: -------------------------------------------------------------------------------- 1 | package lemonsqueezy 2 | -------------------------------------------------------------------------------- /server/service/license/lemonsqueezy/requests_test.go: -------------------------------------------------------------------------------- 1 | package lemonsqueezy 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestValidateLicenseKey(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | key string 13 | expected bool 14 | err error 15 | }{ 16 | { 17 | name: "Testing license key", 18 | key: "26B383EE-95B2-4458-9C58-B376BD6183B1", 19 | expected: false, 20 | }, 21 | { 22 | name: "invalid key", 23 | key: "invalid-key", 24 | expected: false, 25 | }, 26 | } 27 | 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | response, err := ValidateLicenseKey(tt.key, "test-instance") 31 | if tt.err != nil { 32 | require.EqualError(t, err, tt.err.Error()) 33 | return 34 | } 35 | require.NoError(t, err) 36 | require.Equal(t, tt.expected, response.Valid) 37 | }) 38 | } 39 | } 40 | 41 | func TestActiveLicenseKey(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | key string 45 | expected bool 46 | }{ 47 | { 48 | name: "Testing license key", 49 | key: "26B383EE-95B2-4458-9C58-B376BD6183B1", 50 | expected: false, 51 | }, 52 | { 53 | name: "invalid key", 54 | key: "invalid-key", 55 | expected: false, 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | response, err := ActiveLicenseKey(tt.key, "test-instance") 62 | require.NoError(t, err) 63 | require.Equal(t, tt.expected, response.Activated) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/service/license/slash.public.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsNHQEGf4EiGUKG/buu1d 3 | llNjxwmKiUX0htAoBa7JPqNjlQqyd27gBQCJ9b1d4gor3SBbEdKKirph6I/jJ2in 4 | LQwSVtIuQUdILC0PSEyUZ1t/QOOfgNuAW15cvj7e1W2I3GqTy/PwQ08+xTziDiU0 5 | j9fM15vMEx/G378ikPaSfaoLueugI/tpta3Ho6wJqpNr2pL2+pIb1LUurltufA/O 6 | 5mIcxorlu+1iSB5PLB6X1ptipDkD+ZdHlDLzKgzkUoIrqDynC7jlwhiDtqDl2q+j 7 | VTyZKhP6PB81rI4/DXAafrl4ndAGxWiZj83/+m/uzqIx8XWMA10jnSeGvBxbVWrw 8 | rwIDAQAB 9 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /store/activity.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type ActivityType string 8 | 9 | const ( 10 | // ActivityShortcutView is the activity type of shortcut create. 11 | ActivityShortcutCreate ActivityType = "shortcut.create" 12 | // ActivityShortcutView is the activity type of shortcut view. 13 | ActivityShortcutView ActivityType = "shortcut.view" 14 | ) 15 | 16 | func (t ActivityType) String() string { 17 | switch t { 18 | case ActivityShortcutCreate: 19 | return "shortcut.create" 20 | case ActivityShortcutView: 21 | return "shortcut.view" 22 | } 23 | return "" 24 | } 25 | 26 | type ActivityLevel string 27 | 28 | const ( 29 | // ActivityInfo is the activity level of info. 30 | ActivityInfo ActivityLevel = "INFO" 31 | // ActivityWarn is the activity level of warn. 32 | ActivityWarn ActivityLevel = "WARN" 33 | // ActivityError is the activity level of error. 34 | ActivityError ActivityLevel = "ERROR" 35 | ) 36 | 37 | func (l ActivityLevel) String() string { 38 | switch l { 39 | case ActivityInfo: 40 | return "INFO" 41 | case ActivityWarn: 42 | return "WARN" 43 | case ActivityError: 44 | return "ERROR" 45 | } 46 | return "" 47 | } 48 | 49 | type Activity struct { 50 | ID int32 51 | CreatorID int32 52 | CreatedTs int64 53 | Type ActivityType 54 | Level ActivityLevel 55 | Payload string 56 | } 57 | 58 | type FindActivity struct { 59 | Type ActivityType 60 | Level ActivityLevel 61 | PayloadShortcutID *int32 62 | CreatedTsAfter *int64 63 | } 64 | 65 | func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) { 66 | return s.driver.CreateActivity(ctx, create) 67 | } 68 | 69 | func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) { 70 | return s.driver.ListActivities(ctx, find) 71 | } 72 | 73 | func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) { 74 | list, err := s.ListActivities(ctx, find) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | if len(list) == 0 { 80 | return nil, nil 81 | } 82 | 83 | activity := list[0] 84 | return activity, nil 85 | } 86 | -------------------------------------------------------------------------------- /store/cache.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "fmt" 4 | 5 | func getUserSettingCacheKey(userID int32, key string) string { 6 | return fmt.Sprintf("%d-%s", userID, key) 7 | } 8 | -------------------------------------------------------------------------------- /store/collection.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 7 | ) 8 | 9 | type UpdateCollection struct { 10 | ID int32 11 | Name *string 12 | Link *string 13 | Title *string 14 | Description *string 15 | ShortcutIDs []int32 16 | Visibility *storepb.Visibility 17 | } 18 | 19 | type FindCollection struct { 20 | ID *int32 21 | CreatorID *int32 22 | Name *string 23 | VisibilityList []storepb.Visibility 24 | } 25 | 26 | type DeleteCollection struct { 27 | ID int32 28 | } 29 | 30 | func (s *Store) CreateCollection(ctx context.Context, create *storepb.Collection) (*storepb.Collection, error) { 31 | return s.driver.CreateCollection(ctx, create) 32 | } 33 | 34 | func (s *Store) UpdateCollection(ctx context.Context, update *UpdateCollection) (*storepb.Collection, error) { 35 | return s.driver.UpdateCollection(ctx, update) 36 | } 37 | 38 | func (s *Store) ListCollections(ctx context.Context, find *FindCollection) ([]*storepb.Collection, error) { 39 | return s.driver.ListCollections(ctx, find) 40 | } 41 | 42 | func (s *Store) GetCollection(ctx context.Context, find *FindCollection) (*storepb.Collection, error) { 43 | collections, err := s.ListCollections(ctx, find) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | if len(collections) == 0 { 49 | return nil, nil 50 | } 51 | 52 | collection := collections[0] 53 | return collection, nil 54 | } 55 | 56 | func (s *Store) DeleteCollection(ctx context.Context, delete *DeleteCollection) error { 57 | return s.driver.DeleteCollection(ctx, delete) 58 | } 59 | -------------------------------------------------------------------------------- /store/common.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 5 | ) 6 | 7 | func ConvertRowStatusStringToStorepb(status string) storepb.RowStatus { 8 | if status == "NORMAL" { 9 | return storepb.RowStatus_NORMAL 10 | } 11 | // Otherwise, fallback to archived status. 12 | return storepb.RowStatus_ARCHIVED 13 | } 14 | 15 | func ConvertVisibilityStringToStorepb(visibility string) storepb.Visibility { 16 | if visibility == "PUBLIC" { 17 | return storepb.Visibility_PUBLIC 18 | } 19 | // Otherwise, fallback to workspace visibility. 20 | return storepb.Visibility_WORKSPACE 21 | } 22 | -------------------------------------------------------------------------------- /store/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | 6 | "github.com/yourselfhosted/slash/server/profile" 7 | "github.com/yourselfhosted/slash/store" 8 | "github.com/yourselfhosted/slash/store/db/postgres" 9 | "github.com/yourselfhosted/slash/store/db/sqlite" 10 | ) 11 | 12 | // NewDBDriver creates new db driver based on profile. 13 | func NewDBDriver(profile *profile.Profile) (store.Driver, error) { 14 | var driver store.Driver 15 | var err error 16 | 17 | switch profile.Driver { 18 | case "sqlite": 19 | driver, err = sqlite.NewDB(profile) 20 | case "postgres": 21 | driver, err = postgres.NewDB(profile) 22 | default: 23 | return nil, errors.New("unknown db driver") 24 | } 25 | if err != nil { 26 | return nil, errors.Wrap(err, "failed to create db driver") 27 | } 28 | return driver, nil 29 | } 30 | -------------------------------------------------------------------------------- /store/db/postgres/activity.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/yourselfhosted/slash/store" 9 | ) 10 | 11 | func (d *DB) CreateActivity(ctx context.Context, create *store.Activity) (*store.Activity, error) { 12 | stmt := ` 13 | INSERT INTO activity ( 14 | creator_id, 15 | type, 16 | level, 17 | payload 18 | ) 19 | VALUES ($1, $2, $3, $4) 20 | RETURNING id, created_ts 21 | ` 22 | if err := d.db.QueryRowContext(ctx, stmt, 23 | create.CreatorID, 24 | create.Type.String(), 25 | create.Level.String(), 26 | create.Payload, 27 | ).Scan( 28 | &create.ID, 29 | &create.CreatedTs, 30 | ); err != nil { 31 | return nil, err 32 | } 33 | 34 | activity := create 35 | return activity, nil 36 | } 37 | 38 | func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*store.Activity, error) { 39 | where, args := []string{"1 = 1"}, []any{} 40 | if find.Type != "" { 41 | where, args = append(where, "type = "+placeholder(len(args)+1)), append(args, find.Type.String()) 42 | } 43 | if find.Level != "" { 44 | where, args = append(where, "level = "+placeholder(len(args)+1)), append(args, find.Level.String()) 45 | } 46 | if find.PayloadShortcutID != nil { 47 | where, args = append(where, fmt.Sprintf("CAST(payload::JSON->>'shortcutId' AS INTEGER) = %s", placeholder(len(args)+1))), append(args, *find.PayloadShortcutID) 48 | } 49 | if find.CreatedTsAfter != nil { 50 | where, args = append(where, "created_ts > "+placeholder(len(args)+1)), append(args, *find.CreatedTsAfter) 51 | } 52 | 53 | query := ` 54 | SELECT 55 | id, 56 | creator_id, 57 | created_ts, 58 | type, 59 | level, 60 | payload 61 | FROM activity 62 | WHERE ` + strings.Join(where, " AND ") 63 | rows, err := d.db.QueryContext(ctx, query, args...) 64 | if err != nil { 65 | return nil, err 66 | } 67 | defer rows.Close() 68 | 69 | list := []*store.Activity{} 70 | for rows.Next() { 71 | activity := &store.Activity{} 72 | if err := rows.Scan( 73 | &activity.ID, 74 | &activity.CreatorID, 75 | &activity.CreatedTs, 76 | &activity.Type, 77 | &activity.Level, 78 | &activity.Payload, 79 | ); err != nil { 80 | return nil, err 81 | } 82 | 83 | list = append(list, activity) 84 | } 85 | 86 | if err := rows.Err(); err != nil { 87 | return nil, err 88 | } 89 | 90 | return list, nil 91 | } 92 | -------------------------------------------------------------------------------- /store/db/postgres/common.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "google.golang.org/protobuf/encoding/protojson" 8 | ) 9 | 10 | var ( 11 | protojsonUnmarshaler = protojson.UnmarshalOptions{ 12 | DiscardUnknown: true, 13 | } 14 | ) 15 | 16 | func placeholder(n int) string { 17 | return "$" + fmt.Sprint(n) 18 | } 19 | 20 | func placeholders(n int) string { 21 | list := []string{} 22 | for i := 0; i < n; i++ { 23 | list = append(list, placeholder(i+1)) 24 | } 25 | return strings.Join(list, ", ") 26 | } 27 | -------------------------------------------------------------------------------- /store/db/postgres/migration_history.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/yourselfhosted/slash/store" 7 | ) 8 | 9 | func (d *DB) UpsertMigrationHistory(ctx context.Context, upsert *store.UpsertMigrationHistory) (*store.MigrationHistory, error) { 10 | stmt := ` 11 | INSERT INTO migration_history ( 12 | version 13 | ) 14 | VALUES ($1) 15 | ON CONFLICT(version) DO UPDATE 16 | SET 17 | version=EXCLUDED.version 18 | RETURNING version, created_ts 19 | ` 20 | var migrationHistory store.MigrationHistory 21 | if err := d.db.QueryRowContext(ctx, stmt, upsert.Version).Scan( 22 | &migrationHistory.Version, 23 | &migrationHistory.CreatedTs, 24 | ); err != nil { 25 | return nil, err 26 | } 27 | 28 | return &migrationHistory, nil 29 | } 30 | 31 | func (d *DB) ListMigrationHistories(ctx context.Context, _ *store.FindMigrationHistory) ([]*store.MigrationHistory, error) { 32 | query := "SELECT version, created_ts FROM migration_history ORDER BY created_ts DESC" 33 | rows, err := d.db.QueryContext(ctx, query) 34 | if err != nil { 35 | return nil, err 36 | } 37 | defer rows.Close() 38 | 39 | list := make([]*store.MigrationHistory, 0) 40 | for rows.Next() { 41 | var migrationHistory store.MigrationHistory 42 | if err := rows.Scan( 43 | &migrationHistory.Version, 44 | &migrationHistory.CreatedTs, 45 | ); err != nil { 46 | return nil, err 47 | } 48 | 49 | list = append(list, &migrationHistory) 50 | } 51 | 52 | if err := rows.Err(); err != nil { 53 | return nil, err 54 | } 55 | 56 | return list, nil 57 | } 58 | -------------------------------------------------------------------------------- /store/db/postgres/postgres.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | 7 | // Import the PostgreSQL driver. 8 | _ "github.com/lib/pq" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/yourselfhosted/slash/server/profile" 12 | "github.com/yourselfhosted/slash/store" 13 | ) 14 | 15 | type DB struct { 16 | db *sql.DB 17 | profile *profile.Profile 18 | } 19 | 20 | func NewDB(profile *profile.Profile) (store.Driver, error) { 21 | if profile == nil { 22 | return nil, errors.New("profile is nil") 23 | } 24 | 25 | // Open the PostgreSQL connection 26 | db, err := sql.Open("postgres", profile.DSN) 27 | if err != nil { 28 | log.Printf("Failed to open database: %s", err) 29 | return nil, errors.Wrapf(err, "failed to open database: %s", profile.DSN) 30 | } 31 | 32 | var driver store.Driver = &DB{ 33 | db: db, 34 | profile: profile, 35 | } 36 | return driver, nil 37 | } 38 | 39 | func (d *DB) GetDB() *sql.DB { 40 | return d.db 41 | } 42 | 43 | func (d *DB) Close() error { 44 | return d.db.Close() 45 | } 46 | -------------------------------------------------------------------------------- /store/db/sqlite/activity.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/yourselfhosted/slash/store" 8 | ) 9 | 10 | func (d *DB) CreateActivity(ctx context.Context, create *store.Activity) (*store.Activity, error) { 11 | stmt := ` 12 | INSERT INTO activity ( 13 | creator_id, 14 | type, 15 | level, 16 | payload 17 | ) 18 | VALUES (?, ?, ?, ?) 19 | RETURNING id, created_ts 20 | ` 21 | if err := d.db.QueryRowContext(ctx, stmt, 22 | create.CreatorID, 23 | create.Type.String(), 24 | create.Level.String(), 25 | create.Payload, 26 | ).Scan( 27 | &create.ID, 28 | &create.CreatedTs, 29 | ); err != nil { 30 | return nil, err 31 | } 32 | 33 | activity := create 34 | return activity, nil 35 | } 36 | 37 | func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*store.Activity, error) { 38 | where, args := []string{"1 = 1"}, []any{} 39 | if find.Type != "" { 40 | where, args = append(where, "type = ?"), append(args, find.Type.String()) 41 | } 42 | if find.Level != "" { 43 | where, args = append(where, "level = ?"), append(args, find.Level.String()) 44 | } 45 | if find.PayloadShortcutID != nil { 46 | where, args = append(where, "json_extract(payload, '$.shortcutId') = ?"), append(args, *find.PayloadShortcutID) 47 | } 48 | if find.CreatedTsAfter != nil { 49 | where, args = append(where, "created_ts > ?"), append(args, *find.CreatedTsAfter) 50 | } 51 | 52 | query := ` 53 | SELECT 54 | id, 55 | creator_id, 56 | created_ts, 57 | type, 58 | level, 59 | payload 60 | FROM activity 61 | WHERE ` + strings.Join(where, " AND ") 62 | rows, err := d.db.QueryContext(ctx, query, args...) 63 | if err != nil { 64 | return nil, err 65 | } 66 | defer rows.Close() 67 | 68 | list := []*store.Activity{} 69 | for rows.Next() { 70 | activity := &store.Activity{} 71 | if err := rows.Scan( 72 | &activity.ID, 73 | &activity.CreatorID, 74 | &activity.CreatedTs, 75 | &activity.Type, 76 | &activity.Level, 77 | &activity.Payload, 78 | ); err != nil { 79 | return nil, err 80 | } 81 | 82 | list = append(list, activity) 83 | } 84 | 85 | if err := rows.Err(); err != nil { 86 | return nil, err 87 | } 88 | 89 | return list, nil 90 | } 91 | -------------------------------------------------------------------------------- /store/db/sqlite/common.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import "google.golang.org/protobuf/encoding/protojson" 4 | 5 | var ( 6 | protojsonUnmarshaler = protojson.UnmarshalOptions{ 7 | DiscardUnknown: true, 8 | } 9 | ) 10 | -------------------------------------------------------------------------------- /store/db/sqlite/migration_history.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/yourselfhosted/slash/store" 7 | ) 8 | 9 | func (d *DB) UpsertMigrationHistory(ctx context.Context, upsert *store.UpsertMigrationHistory) (*store.MigrationHistory, error) { 10 | stmt := ` 11 | INSERT INTO migration_history ( 12 | version 13 | ) 14 | VALUES (?) 15 | ON CONFLICT(version) DO UPDATE 16 | SET 17 | version=EXCLUDED.version 18 | RETURNING version, created_ts 19 | ` 20 | var migrationHistory store.MigrationHistory 21 | if err := d.db.QueryRowContext(ctx, stmt, upsert.Version).Scan( 22 | &migrationHistory.Version, 23 | &migrationHistory.CreatedTs, 24 | ); err != nil { 25 | return nil, err 26 | } 27 | 28 | return &migrationHistory, nil 29 | } 30 | 31 | func (d *DB) ListMigrationHistories(ctx context.Context, _ *store.FindMigrationHistory) ([]*store.MigrationHistory, error) { 32 | query := "SELECT `version`, `created_ts` FROM `migration_history` ORDER BY `created_ts` DESC" 33 | rows, err := d.db.QueryContext(ctx, query) 34 | if err != nil { 35 | return nil, err 36 | } 37 | defer rows.Close() 38 | 39 | list := make([]*store.MigrationHistory, 0) 40 | for rows.Next() { 41 | var migrationHistory store.MigrationHistory 42 | if err := rows.Scan( 43 | &migrationHistory.Version, 44 | &migrationHistory.CreatedTs, 45 | ); err != nil { 46 | return nil, err 47 | } 48 | 49 | list = append(list, &migrationHistory) 50 | } 51 | 52 | if err := rows.Err(); err != nil { 53 | return nil, err 54 | } 55 | 56 | return list, nil 57 | } 58 | -------------------------------------------------------------------------------- /store/db/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pkg/errors" 7 | // SQLite driver. 8 | _ "modernc.org/sqlite" 9 | 10 | "github.com/yourselfhosted/slash/server/profile" 11 | "github.com/yourselfhosted/slash/store" 12 | ) 13 | 14 | type DB struct { 15 | db *sql.DB 16 | profile *profile.Profile 17 | } 18 | 19 | // NewDB opens a database specified by its database driver name and a 20 | // driver-specific data source name, usually consisting of at least a 21 | // database name and connection information. 22 | func NewDB(profile *profile.Profile) (store.Driver, error) { 23 | // Ensure a DSN is set before attempting to open the database. 24 | if profile.DSN == "" { 25 | return nil, errors.New("dsn required") 26 | } 27 | 28 | // Connect to the database with some sane settings: 29 | // - No shared-cache: it's obsolete; WAL journal mode is a better solution. 30 | // - No foreign key constraints: it's currently disabled by default, but it's a 31 | // good practice to be explicit and prevent future surprises on SQLite upgrades. 32 | // - Journal mode set to WAL: it's the recommended journal mode for most applications 33 | // as it prevents locking issues. 34 | // 35 | // Notes: 36 | // - When using the `modernc.org/sqlite` driver, each pragma must be prefixed with `_pragma=`. 37 | // 38 | // References: 39 | // - https://pkg.go.dev/modernc.org/sqlite#Driver.Open 40 | // - https://www.sqlite.org/sharedcache.html 41 | // - https://www.sqlite.org/pragma.html 42 | sqliteDB, err := sql.Open("sqlite", profile.DSN+"?_pragma=foreign_keys(0)&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)") 43 | if err != nil { 44 | return nil, errors.Wrapf(err, "failed to open db with dsn: %s", profile.DSN) 45 | } 46 | 47 | driver := DB{db: sqliteDB, profile: profile} 48 | 49 | return &driver, nil 50 | } 51 | 52 | func (d *DB) GetDB() *sql.DB { 53 | return d.db 54 | } 55 | 56 | func (d *DB) Close() error { 57 | return d.db.Close() 58 | } 59 | -------------------------------------------------------------------------------- /store/driver.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 8 | ) 9 | 10 | // Driver is an interface for store driver. 11 | // It contains all methods that store database driver should implement. 12 | type Driver interface { 13 | GetDB() *sql.DB 14 | Close() error 15 | 16 | // MigrationHistory model related methods. 17 | UpsertMigrationHistory(ctx context.Context, upsert *UpsertMigrationHistory) (*MigrationHistory, error) 18 | ListMigrationHistories(ctx context.Context, find *FindMigrationHistory) ([]*MigrationHistory, error) 19 | 20 | // Activity model related methods. 21 | CreateActivity(ctx context.Context, create *Activity) (*Activity, error) 22 | ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) 23 | 24 | // Collection model related methods. 25 | CreateCollection(ctx context.Context, create *storepb.Collection) (*storepb.Collection, error) 26 | UpdateCollection(ctx context.Context, update *UpdateCollection) (*storepb.Collection, error) 27 | ListCollections(ctx context.Context, find *FindCollection) ([]*storepb.Collection, error) 28 | DeleteCollection(ctx context.Context, delete *DeleteCollection) error 29 | 30 | // Shortcut model related methods. 31 | CreateShortcut(ctx context.Context, create *storepb.Shortcut) (*storepb.Shortcut, error) 32 | UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*storepb.Shortcut, error) 33 | ListShortcuts(ctx context.Context, find *FindShortcut) ([]*storepb.Shortcut, error) 34 | DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error 35 | 36 | // User model related methods. 37 | CreateUser(ctx context.Context, create *User) (*User, error) 38 | UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) 39 | ListUsers(ctx context.Context, find *FindUser) ([]*User, error) 40 | DeleteUser(ctx context.Context, delete *DeleteUser) error 41 | 42 | // UserSetting model related methods. 43 | UpsertUserSetting(ctx context.Context, upsert *storepb.UserSetting) (*storepb.UserSetting, error) 44 | ListUserSettings(ctx context.Context, find *FindUserSetting) ([]*storepb.UserSetting, error) 45 | 46 | // WorkspaceSetting model related methods. 47 | UpsertWorkspaceSetting(ctx context.Context, upsert *storepb.WorkspaceSetting) (*storepb.WorkspaceSetting, error) 48 | ListWorkspaceSettings(ctx context.Context, find *FindWorkspaceSetting) ([]*storepb.WorkspaceSetting, error) 49 | DeleteWorkspaceSetting(ctx context.Context, key storepb.WorkspaceSettingKey) error 50 | } 51 | -------------------------------------------------------------------------------- /store/migration/postgres/prod/1.0/00__visibility.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE shortcut DROP CONSTRAINT IF EXISTS shortcut_visibility_check; 2 | 3 | ALTER TABLE shortcut ALTER COLUMN visibility SET DEFAULT 'WORKSPACE'; 4 | 5 | UPDATE shortcut SET visibility = 'WORKSPACE' WHERE visibility = 'PRIVATE'; 6 | 7 | ALTER TABLE collection DROP CONSTRAINT IF EXISTS collection_visibility_check; 8 | 9 | ALTER TABLE collection ALTER COLUMN visibility SET DEFAULT 'WORKSPACE'; 10 | 11 | UPDATE collection SET visibility = 'WORKSPACE' WHERE visibility = 'PRIVATE'; 12 | -------------------------------------------------------------------------------- /store/migration/sqlite/dev/LATEST.sql: -------------------------------------------------------------------------------- 1 | -- migration_history 2 | CREATE TABLE migration_history ( 3 | version TEXT NOT NULL PRIMARY KEY, 4 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')) 5 | ); 6 | 7 | -- workspace_setting 8 | CREATE TABLE workspace_setting ( 9 | key TEXT NOT NULL UNIQUE, 10 | value TEXT NOT NULL 11 | ); 12 | 13 | -- user 14 | CREATE TABLE user ( 15 | id INTEGER PRIMARY KEY AUTOINCREMENT, 16 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 17 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 18 | row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', 19 | email TEXT NOT NULL UNIQUE, 20 | nickname TEXT NOT NULL, 21 | password_hash TEXT NOT NULL, 22 | role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER' 23 | ); 24 | 25 | CREATE INDEX idx_user_email ON user(email); 26 | 27 | -- user_setting 28 | CREATE TABLE user_setting ( 29 | user_id INTEGER NOT NULL, 30 | key TEXT NOT NULL, 31 | value TEXT NOT NULL, 32 | UNIQUE(user_id, key) 33 | ); 34 | 35 | -- shortcut 36 | CREATE TABLE shortcut ( 37 | id INTEGER PRIMARY KEY AUTOINCREMENT, 38 | creator_id INTEGER NOT NULL, 39 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 40 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 41 | row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', 42 | name TEXT NOT NULL UNIQUE, 43 | link TEXT NOT NULL, 44 | title TEXT NOT NULL DEFAULT '', 45 | description TEXT NOT NULL DEFAULT '', 46 | visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE', 47 | tag TEXT NOT NULL DEFAULT '', 48 | og_metadata TEXT NOT NULL DEFAULT '{}' 49 | ); 50 | 51 | CREATE INDEX idx_shortcut_name ON shortcut(name); 52 | 53 | -- activity 54 | CREATE TABLE activity ( 55 | id INTEGER PRIMARY KEY AUTOINCREMENT, 56 | creator_id INTEGER NOT NULL, 57 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 58 | type TEXT NOT NULL DEFAULT '', 59 | level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO', 60 | payload TEXT NOT NULL DEFAULT '{}' 61 | ); 62 | 63 | -- collection 64 | CREATE TABLE collection ( 65 | id INTEGER PRIMARY KEY AUTOINCREMENT, 66 | creator_id INTEGER NOT NULL, 67 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 68 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 69 | name TEXT NOT NULL UNIQUE, 70 | title TEXT NOT NULL DEFAULT '', 71 | description TEXT NOT NULL DEFAULT '', 72 | shortcut_ids INTEGER[] NOT NULL, 73 | visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE' 74 | ); 75 | 76 | CREATE INDEX idx_collection_name ON collection(name); 77 | -------------------------------------------------------------------------------- /store/migration/sqlite/prod/0.2/00__create_index.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS idx_user_email ON user(email); 2 | CREATE INDEX IF NOT EXISTS idx_shortcut_name ON shortcut(name); 3 | -------------------------------------------------------------------------------- /store/migration/sqlite/prod/0.3/00__add_og_metadata.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE shortcut ADD COLUMN og_metadata TEXT NOT NULL DEFAULT '{}'; 2 | -------------------------------------------------------------------------------- /store/migration/sqlite/prod/0.4/00__add_shortcut_title.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE shortcut ADD COLUMN title TEXT NOT NULL DEFAULT ''; 2 | -------------------------------------------------------------------------------- /store/migration/sqlite/prod/0.5/00__drop_idp.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS idp; 2 | -------------------------------------------------------------------------------- /store/migration/sqlite/prod/0.5/01__collection.sql: -------------------------------------------------------------------------------- 1 | -- collection 2 | CREATE TABLE collection ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | creator_id INTEGER NOT NULL, 5 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 6 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 7 | name TEXT NOT NULL UNIQUE, 8 | title TEXT NOT NULL DEFAULT '', 9 | description TEXT NOT NULL DEFAULT '', 10 | shortcut_ids INTEGER[] NOT NULL, 11 | visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE' 12 | ); 13 | 14 | CREATE INDEX idx_collection_name ON collection(name); 15 | -------------------------------------------------------------------------------- /store/migration/sqlite/prod/1.0/00__visibility.sql: -------------------------------------------------------------------------------- 1 | UPDATE shortcut SET visibility = 'WORKSPACE' WHERE visibility = 'PRIVATE'; 2 | 3 | ALTER TABLE shortcut RENAME TO shortcut_old; 4 | 5 | CREATE TABLE shortcut ( 6 | id INTEGER PRIMARY KEY AUTOINCREMENT, 7 | creator_id INTEGER NOT NULL, 8 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 9 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 10 | row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', 11 | name TEXT NOT NULL UNIQUE, 12 | link TEXT NOT NULL, 13 | title TEXT NOT NULL DEFAULT '', 14 | description TEXT NOT NULL DEFAULT '', 15 | visibility TEXT NOT NULL DEFAULT 'WORKSPACE', 16 | tag TEXT NOT NULL DEFAULT '', 17 | og_metadata TEXT NOT NULL DEFAULT '{}' 18 | ); 19 | 20 | INSERT INTO shortcut ( 21 | id, 22 | creator_id, 23 | created_ts, 24 | updated_ts, 25 | row_status, 26 | name, 27 | link, 28 | title, 29 | description, 30 | visibility, 31 | tag, 32 | og_metadata 33 | ) 34 | SELECT 35 | id, 36 | creator_id, 37 | created_ts, 38 | updated_ts, 39 | row_status, 40 | name, 41 | link, 42 | title, 43 | description, 44 | visibility, 45 | tag, 46 | og_metadata 47 | FROM shortcut_old; 48 | 49 | DROP TABLE shortcut_old; 50 | 51 | UPDATE collection SET visibility = 'WORKSPACE' WHERE visibility = 'PRIVATE'; 52 | 53 | ALTER TABLE collection RENAME TO collection_old; 54 | 55 | CREATE TABLE collection ( 56 | id INTEGER PRIMARY KEY AUTOINCREMENT, 57 | creator_id INTEGER NOT NULL, 58 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 59 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 60 | name TEXT NOT NULL UNIQUE, 61 | title TEXT NOT NULL DEFAULT '', 62 | description TEXT NOT NULL DEFAULT '', 63 | shortcut_ids INTEGER[] NOT NULL, 64 | visibility TEXT NOT NULL DEFAULT 'WORKSPACE' 65 | ); 66 | 67 | INSERT INTO collection ( 68 | id, 69 | creator_id, 70 | created_ts, 71 | updated_ts, 72 | name, 73 | title, 74 | description, 75 | shortcut_ids, 76 | visibility 77 | ) 78 | SELECT 79 | id, 80 | creator_id, 81 | created_ts, 82 | updated_ts, 83 | name, 84 | title, 85 | description, 86 | shortcut_ids, 87 | visibility 88 | FROM collection_old; 89 | 90 | DROP TABLE collection_old; 91 | -------------------------------------------------------------------------------- /store/migration/sqlite/prod/LATEST.sql: -------------------------------------------------------------------------------- 1 | -- migration_history 2 | CREATE TABLE migration_history ( 3 | version TEXT NOT NULL PRIMARY KEY, 4 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')) 5 | ); 6 | 7 | -- workspace_setting 8 | CREATE TABLE workspace_setting ( 9 | key TEXT NOT NULL UNIQUE, 10 | value TEXT NOT NULL 11 | ); 12 | 13 | -- user 14 | CREATE TABLE user ( 15 | id INTEGER PRIMARY KEY AUTOINCREMENT, 16 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 17 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 18 | row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', 19 | email TEXT NOT NULL UNIQUE, 20 | nickname TEXT NOT NULL, 21 | password_hash TEXT NOT NULL, 22 | role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER' 23 | ); 24 | 25 | CREATE INDEX idx_user_email ON user(email); 26 | 27 | -- user_setting 28 | CREATE TABLE user_setting ( 29 | user_id INTEGER NOT NULL, 30 | key TEXT NOT NULL, 31 | value TEXT NOT NULL, 32 | UNIQUE(user_id, key) 33 | ); 34 | 35 | -- shortcut 36 | CREATE TABLE shortcut ( 37 | id INTEGER PRIMARY KEY AUTOINCREMENT, 38 | creator_id INTEGER NOT NULL, 39 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 40 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 41 | row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL', 42 | name TEXT NOT NULL UNIQUE, 43 | link TEXT NOT NULL, 44 | title TEXT NOT NULL DEFAULT '', 45 | description TEXT NOT NULL DEFAULT '', 46 | visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE', 47 | tag TEXT NOT NULL DEFAULT '', 48 | og_metadata TEXT NOT NULL DEFAULT '{}' 49 | ); 50 | 51 | CREATE INDEX idx_shortcut_name ON shortcut(name); 52 | 53 | -- activity 54 | CREATE TABLE activity ( 55 | id INTEGER PRIMARY KEY AUTOINCREMENT, 56 | creator_id INTEGER NOT NULL, 57 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 58 | type TEXT NOT NULL DEFAULT '', 59 | level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO', 60 | payload TEXT NOT NULL DEFAULT '{}' 61 | ); 62 | 63 | -- collection 64 | CREATE TABLE collection ( 65 | id INTEGER PRIMARY KEY AUTOINCREMENT, 66 | creator_id INTEGER NOT NULL, 67 | created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 68 | updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), 69 | name TEXT NOT NULL UNIQUE, 70 | title TEXT NOT NULL DEFAULT '', 71 | description TEXT NOT NULL DEFAULT '', 72 | shortcut_ids INTEGER[] NOT NULL, 73 | visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE' 74 | ); 75 | 76 | CREATE INDEX idx_collection_name ON collection(name); 77 | -------------------------------------------------------------------------------- /store/migration_history.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | type MigrationHistory struct { 4 | Version string 5 | CreatedTs int64 6 | } 7 | 8 | type UpsertMigrationHistory struct { 9 | Version string 10 | } 11 | 12 | type FindMigrationHistory struct { 13 | } 14 | -------------------------------------------------------------------------------- /store/seed/sqlite/10000__user.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO 2 | user ( 3 | `id`, 4 | `role`, 5 | `email`, 6 | `nickname`, 7 | `password_hash` 8 | ) 9 | VALUES 10 | ( 11 | 101, 12 | 'ADMIN', 13 | 'slash@yourselfhosted.com', 14 | 'Slasher', 15 | '$2a$10$H8HBWGcG/hoePhFy5SiNKOHxMD6omIpyEEWbl/fIorFC814bXW.Ua' 16 | ); 17 | 18 | INSERT INTO 19 | user ( 20 | `id`, 21 | `role`, 22 | `email`, 23 | `nickname`, 24 | `password_hash` 25 | ) 26 | VALUES 27 | ( 28 | 102, 29 | 'USER', 30 | 'steven@yourselfhosted.com', 31 | 'Steven', 32 | -- raw password: secret 33 | '$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK' 34 | ); 35 | -------------------------------------------------------------------------------- /store/seed/sqlite/10001__shortcut.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO 2 | shortcut ( 3 | `id`, 4 | `creator_id`, 5 | `name`, 6 | `link`, 7 | `visibility` 8 | ) 9 | VALUES 10 | ( 11 | 1, 12 | 101, 13 | 'discord', 14 | 'https://discord.gg/QZqUuUAhDV', 15 | 'PUBLIC' 16 | ); 17 | 18 | INSERT INTO 19 | shortcut ( 20 | `id`, 21 | `creator_id`, 22 | `name`, 23 | `link`, 24 | `visibility`, 25 | `tag`, 26 | `og_metadata` 27 | ) 28 | VALUES 29 | ( 30 | 2, 31 | 101, 32 | 'ai-infra', 33 | 'https://star-history.com/blog/open-source-ai-infra-projects', 34 | 'PUBLIC', 35 | 'star-history ai', 36 | '{"title":"Open Source AI Infra for Your Next Project","description":"Some open-source infra projects that can be directly used for your next project. 💡","image":"https://star-history.com/blog/assets/open-source-ai-infra-projects/banner.webp"}' 37 | ); 38 | 39 | INSERT INTO 40 | shortcut ( 41 | `id`, 42 | `creator_id`, 43 | `name`, 44 | `link`, 45 | `visibility`, 46 | `tag`, 47 | `og_metadata` 48 | ) 49 | VALUES 50 | ( 51 | 3, 52 | 101, 53 | 'schema-change', 54 | 'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change', 55 | 'PUBLIC', 56 | 'database article👍', 57 | '{"title":"How to Handle Database Migration / Schema Change?","description":"A database schema is the structure of a database, which describes the relationships between the different tables and fields in the database. A database schema change, also known as schema migration, or simply migration refers to any alteration to this structure, such as adding a new table, modifying the data type of a field, or changing the relationships between tables.","image":"https://www.bytebase.com/_next/image/?url=%2Fcontent%2Fblog%2Fhow-to-handle-database-schema-change%2Fchange.webp\u0026w=2048\u0026q=75"}' 58 | ); 59 | 60 | INSERT INTO 61 | shortcut ( 62 | `id`, 63 | `creator_id`, 64 | `name`, 65 | `link`, 66 | `tag`, 67 | `visibility` 68 | ) 69 | VALUES 70 | ( 71 | 4, 72 | 101, 73 | 'sqlchat', 74 | 'https://www.sqlchat.ai', 75 | 'ai chatbot sql', 76 | 'WORKSPACE' 77 | ); 78 | 79 | INSERT INTO 80 | shortcut ( 81 | `id`, 82 | `creator_id`, 83 | `name`, 84 | `link`, 85 | `visibility` 86 | ) 87 | VALUES 88 | ( 89 | 5, 90 | 102, 91 | 'stevenlgtm', 92 | 'https://github.com/boojack', 93 | 'PUBLIC' 94 | ); 95 | -------------------------------------------------------------------------------- /store/seed/sqlite/10002__collection.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO 2 | collection ( 3 | `id`, 4 | `creator_id`, 5 | `name`, 6 | `title`, 7 | `description`, 8 | `visibility`, 9 | `shortcut_ids` 10 | ) 11 | VALUES 12 | ( 13 | 1, 14 | 101, 15 | 'minecraft', 16 | 'Minecraft', 17 | 'My daily thoughts and ideas', 18 | 'PUBLIC', 19 | '1,2,3,4,5' 20 | ); 21 | -------------------------------------------------------------------------------- /store/shortcut.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 7 | ) 8 | 9 | type UpdateShortcut struct { 10 | ID int32 11 | 12 | Name *string 13 | Link *string 14 | Title *string 15 | Description *string 16 | Visibility *storepb.Visibility 17 | Tag *string 18 | OpenGraphMetadata *storepb.OpenGraphMetadata 19 | } 20 | 21 | type FindShortcut struct { 22 | ID *int32 23 | CreatorID *int32 24 | Name *string 25 | VisibilityList []storepb.Visibility 26 | Tag *string 27 | } 28 | 29 | type DeleteShortcut struct { 30 | ID int32 31 | } 32 | 33 | func (s *Store) CreateShortcut(ctx context.Context, create *storepb.Shortcut) (*storepb.Shortcut, error) { 34 | shortcut, err := s.driver.CreateShortcut(ctx, create) 35 | if err != nil { 36 | return nil, err 37 | } 38 | s.shortcutCache.Store(shortcut.Id, shortcut) 39 | return shortcut, nil 40 | } 41 | 42 | func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*storepb.Shortcut, error) { 43 | shortcut, err := s.driver.UpdateShortcut(ctx, update) 44 | if err != nil { 45 | return nil, err 46 | } 47 | s.shortcutCache.Store(shortcut.Id, shortcut) 48 | return shortcut, nil 49 | } 50 | 51 | func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*storepb.Shortcut, error) { 52 | list, err := s.driver.ListShortcuts(ctx, find) 53 | if err != nil { 54 | return nil, err 55 | } 56 | for _, shortcut := range list { 57 | s.shortcutCache.Store(shortcut.Id, shortcut) 58 | } 59 | return list, nil 60 | } 61 | 62 | func (s *Store) GetShortcut(ctx context.Context, find *FindShortcut) (*storepb.Shortcut, error) { 63 | if find.ID != nil { 64 | if cache, ok := s.shortcutCache.Load(*find.ID); ok { 65 | if shortcut, ok := cache.(*storepb.Shortcut); ok { 66 | return shortcut, nil 67 | } 68 | } 69 | } 70 | 71 | shortcuts, err := s.ListShortcuts(ctx, find) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if len(shortcuts) == 0 { 77 | return nil, nil 78 | } 79 | 80 | shortcut := shortcuts[0] 81 | s.shortcutCache.Store(shortcut.Id, shortcut) 82 | return shortcut, nil 83 | } 84 | 85 | func (s *Store) DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error { 86 | if err := s.driver.DeleteShortcut(ctx, delete); err != nil { 87 | return err 88 | } 89 | 90 | s.shortcutCache.Delete(delete.ID) 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/yourselfhosted/slash/server/profile" 7 | ) 8 | 9 | // Store provides database access to all raw objects. 10 | type Store struct { 11 | profile *profile.Profile 12 | driver Driver 13 | 14 | workspaceSettingCache sync.Map // map[string]*WorkspaceSetting 15 | userCache sync.Map // map[int]*User 16 | userSettingCache sync.Map // map[string]*UserSetting 17 | shortcutCache sync.Map // map[int]*Shortcut 18 | } 19 | 20 | // New creates a new instance of Store. 21 | func New(driver Driver, profile *profile.Profile) *Store { 22 | return &Store{ 23 | driver: driver, 24 | profile: profile, 25 | } 26 | } 27 | 28 | // Close closes the database connection. 29 | func (s *Store) Close() error { 30 | return s.driver.Close() 31 | } 32 | -------------------------------------------------------------------------------- /store/test/activity_test.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/yourselfhosted/slash/store" 10 | ) 11 | 12 | func TestActivityStore(t *testing.T) { 13 | ctx := context.Background() 14 | ts := NewTestingStore(ctx, t) 15 | user, err := createTestingAdminUser(ctx, ts) 16 | require.NoError(t, err) 17 | list, err := ts.ListActivities(ctx, &store.FindActivity{}) 18 | require.NoError(t, err) 19 | require.Equal(t, 0, len(list)) 20 | activity, err := ts.CreateActivity(ctx, &store.Activity{ 21 | CreatorID: user.ID, 22 | Type: store.ActivityShortcutCreate, 23 | Level: store.ActivityInfo, 24 | Payload: "", 25 | }) 26 | require.NoError(t, err) 27 | list, err = ts.ListActivities(ctx, &store.FindActivity{}) 28 | require.NoError(t, err) 29 | require.Equal(t, 1, len(list)) 30 | require.Equal(t, activity, list[0]) 31 | } 32 | -------------------------------------------------------------------------------- /store/test/collection_test.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 10 | "github.com/yourselfhosted/slash/store" 11 | ) 12 | 13 | func TestCollectionStore(t *testing.T) { 14 | ctx := context.Background() 15 | ts := NewTestingStore(ctx, t) 16 | user, err := createTestingAdminUser(ctx, ts) 17 | require.NoError(t, err) 18 | collection, err := ts.CreateCollection(ctx, &storepb.Collection{ 19 | CreatorId: user.ID, 20 | Name: "test", 21 | Title: "My collection", 22 | Description: "A test collection", 23 | ShortcutIds: []int32{101, 102}, 24 | Visibility: storepb.Visibility_WORKSPACE, 25 | }) 26 | require.NoError(t, err) 27 | collections, err := ts.ListCollections(ctx, &store.FindCollection{ 28 | CreatorID: &user.ID, 29 | }) 30 | require.NoError(t, err) 31 | require.Equal(t, 1, len(collections)) 32 | require.Equal(t, collection, collections[0]) 33 | newTitle := "My new collection" 34 | newShortcutIDs := []int32{101, 103} 35 | updatedCollection, err := ts.UpdateCollection(ctx, &store.UpdateCollection{ 36 | ID: collection.Id, 37 | Title: &newTitle, 38 | ShortcutIDs: newShortcutIDs, 39 | }) 40 | require.NoError(t, err) 41 | require.Equal(t, newTitle, updatedCollection.Title) 42 | require.Equal(t, newShortcutIDs, updatedCollection.ShortcutIds) 43 | err = ts.DeleteCollection(ctx, &store.DeleteCollection{ 44 | ID: collection.Id, 45 | }) 46 | require.NoError(t, err) 47 | collections, err = ts.ListCollections(ctx, &store.FindCollection{ 48 | CreatorID: &user.ID, 49 | }) 50 | require.NoError(t, err) 51 | require.Equal(t, 0, len(collections)) 52 | } 53 | -------------------------------------------------------------------------------- /store/test/migrator_test.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetCurrentSchemaVersion(t *testing.T) { 11 | ctx := context.Background() 12 | ts := NewTestingStore(ctx, t) 13 | 14 | currentSchemaVersion, err := ts.GetCurrentSchemaVersion() 15 | require.NoError(t, err) 16 | require.Equal(t, "1.0.1", currentSchemaVersion) 17 | } 18 | -------------------------------------------------------------------------------- /store/test/shortcut_test.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 10 | "github.com/yourselfhosted/slash/store" 11 | ) 12 | 13 | func TestShortcutStore(t *testing.T) { 14 | ctx := context.Background() 15 | ts := NewTestingStore(ctx, t) 16 | user, err := createTestingAdminUser(ctx, ts) 17 | require.NoError(t, err) 18 | shortcut, err := ts.CreateShortcut(ctx, &storepb.Shortcut{ 19 | CreatorId: user.ID, 20 | Name: "test", 21 | Link: "https://test.link", 22 | Description: "A test shortcut", 23 | Visibility: storepb.Visibility_WORKSPACE, 24 | Tags: []string{"test", "shortcut"}, 25 | OgMetadata: &storepb.OpenGraphMetadata{}, 26 | }) 27 | require.NoError(t, err) 28 | shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{ 29 | CreatorID: &user.ID, 30 | }) 31 | require.NoError(t, err) 32 | require.Equal(t, 1, len(shortcuts)) 33 | require.Equal(t, shortcut, shortcuts[0]) 34 | newLink := "https://new.link" 35 | updatedShortcut, err := ts.UpdateShortcut(ctx, &store.UpdateShortcut{ 36 | ID: shortcut.Id, 37 | Link: &newLink, 38 | }) 39 | require.NoError(t, err) 40 | require.Equal(t, newLink, updatedShortcut.Link) 41 | tag := "test" 42 | shortcut, err = ts.GetShortcut(ctx, &store.FindShortcut{ 43 | Tag: &tag, 44 | }) 45 | require.NoError(t, err) 46 | err = ts.DeleteShortcut(ctx, &store.DeleteShortcut{ 47 | ID: shortcut.Id, 48 | }) 49 | require.NoError(t, err) 50 | shortcuts, err = ts.ListShortcuts(ctx, &store.FindShortcut{ 51 | CreatorID: &user.ID, 52 | }) 53 | require.NoError(t, err) 54 | require.Equal(t, 0, len(shortcuts)) 55 | } 56 | -------------------------------------------------------------------------------- /store/test/store.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "os" 8 | "testing" 9 | 10 | "github.com/joho/godotenv" 11 | 12 | "github.com/yourselfhosted/slash/server/common" 13 | "github.com/yourselfhosted/slash/server/profile" 14 | "github.com/yourselfhosted/slash/store" 15 | "github.com/yourselfhosted/slash/store/db" 16 | ) 17 | 18 | func NewTestingStore(ctx context.Context, t *testing.T) *store.Store { 19 | profile := getTestingProfile(t) 20 | dbDriver, err := db.NewDBDriver(profile) 21 | if err != nil { 22 | fmt.Printf("failed to create db driver, error: %+v\n", err) 23 | } 24 | resetTestingDB(ctx, profile, dbDriver) 25 | store := store.New(dbDriver, profile) 26 | if err := store.Migrate(ctx); err != nil { 27 | fmt.Printf("failed to migrate db, error: %+v\n", err) 28 | } 29 | return store 30 | } 31 | 32 | func resetTestingDB(ctx context.Context, profile *profile.Profile, dbDriver store.Driver) { 33 | if profile.Driver == "postgres" { 34 | _, err := dbDriver.GetDB().ExecContext(ctx, ` 35 | DROP TABLE IF EXISTS migration_history CASCADE; 36 | DROP TABLE IF EXISTS workspace_setting CASCADE; 37 | DROP TABLE IF EXISTS "user" CASCADE; 38 | DROP TABLE IF EXISTS user_setting CASCADE; 39 | DROP TABLE IF EXISTS shortcut CASCADE; 40 | DROP TABLE IF EXISTS activity CASCADE; 41 | DROP TABLE IF EXISTS collection CASCADE;`) 42 | if err != nil { 43 | fmt.Printf("failed to reset testing db, error: %+v\n", err) 44 | panic(err) 45 | } 46 | } 47 | } 48 | 49 | func getUnusedPort() int { 50 | // Get a random unused port 51 | listener, err := net.Listen("tcp", "localhost:0") 52 | if err != nil { 53 | panic(err) 54 | } 55 | defer listener.Close() 56 | 57 | // Get the port number 58 | port := listener.Addr().(*net.TCPAddr).Port 59 | return port 60 | } 61 | 62 | func getTestingProfile(t *testing.T) *profile.Profile { 63 | if err := godotenv.Load(".env"); err != nil { 64 | t.Log("failed to load .env file, but it's ok") 65 | } 66 | 67 | // Get a temporary directory for the test data. 68 | dir := t.TempDir() 69 | mode := "prod" 70 | port := getUnusedPort() 71 | driver := getDriverFromEnv() 72 | dsn := os.Getenv("DSN") 73 | if driver == "sqlite" { 74 | dsn = fmt.Sprintf("%s/slash_%s.db", dir, mode) 75 | } 76 | return &profile.Profile{ 77 | Mode: mode, 78 | Port: port, 79 | Data: dir, 80 | DSN: dsn, 81 | Driver: driver, 82 | Version: common.GetCurrentVersion(mode), 83 | } 84 | } 85 | 86 | func getDriverFromEnv() string { 87 | driver := os.Getenv("DRIVER") 88 | if driver == "" { 89 | driver = "sqlite" 90 | } 91 | return driver 92 | } 93 | -------------------------------------------------------------------------------- /store/test/user_test.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "golang.org/x/crypto/bcrypt" 9 | 10 | "github.com/yourselfhosted/slash/store" 11 | ) 12 | 13 | func TestUserStore(t *testing.T) { 14 | ctx := context.Background() 15 | ts := NewTestingStore(ctx, t) 16 | user, err := createTestingAdminUser(ctx, ts) 17 | require.NoError(t, err) 18 | users, err := ts.ListUsers(ctx, &store.FindUser{}) 19 | require.NoError(t, err) 20 | require.Equal(t, 1, len(users)) 21 | require.Equal(t, store.RoleAdmin, users[0].Role) 22 | require.Equal(t, user, users[0]) 23 | userPatchNickname := "test_nickname_2" 24 | user, err = ts.UpdateUser(ctx, &store.UpdateUser{ 25 | ID: user.ID, 26 | Nickname: &userPatchNickname, 27 | }) 28 | require.NoError(t, err) 29 | require.Equal(t, userPatchNickname, user.Nickname) 30 | err = ts.DeleteUser(ctx, &store.DeleteUser{ 31 | ID: user.ID, 32 | }) 33 | require.NoError(t, err) 34 | users, err = ts.ListUsers(ctx, &store.FindUser{}) 35 | require.NoError(t, err) 36 | require.Equal(t, 0, len(users)) 37 | } 38 | 39 | // createTestingAdminUser creates a testing admin user. 40 | func createTestingAdminUser(ctx context.Context, ts *store.Store) (*store.User, error) { 41 | userCreate := &store.User{ 42 | Role: store.RoleAdmin, 43 | Email: "test@test.com", 44 | Nickname: "test_nickname", 45 | } 46 | passwordHash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.DefaultCost) 47 | if err != nil { 48 | return nil, err 49 | } 50 | userCreate.PasswordHash = string(passwordHash) 51 | user, err := ts.CreateUser(ctx, userCreate) 52 | return user, err 53 | } 54 | -------------------------------------------------------------------------------- /store/test/workspace_setting_test.go: -------------------------------------------------------------------------------- 1 | package teststore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/google/uuid" 8 | "github.com/stretchr/testify/require" 9 | 10 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 11 | "github.com/yourselfhosted/slash/store" 12 | ) 13 | 14 | func TestWorkspaceSettingStore(t *testing.T) { 15 | ctx := context.Background() 16 | ts := NewTestingStore(ctx, t) 17 | tempSecret := uuid.New().String() 18 | workspaceSetting, err := ts.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{ 19 | Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_GENERAL, 20 | Value: &storepb.WorkspaceSetting_General{ 21 | General: &storepb.WorkspaceSetting_GeneralSetting{ 22 | SecretSession: tempSecret, 23 | }, 24 | }, 25 | }) 26 | require.NoError(t, err) 27 | foundWorkspaceSetting, err := ts.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{ 28 | Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_GENERAL, 29 | }) 30 | require.NoError(t, err) 31 | require.Equal(t, workspaceSetting, foundWorkspaceSetting) 32 | workspaceSettings, err := ts.ListWorkspaceSettings(ctx, &store.FindWorkspaceSetting{}) 33 | require.NoError(t, err) 34 | require.Equal(t, 1, len(workspaceSettings)) 35 | require.Equal(t, foundWorkspaceSetting, workspaceSettings[0]) 36 | } 37 | -------------------------------------------------------------------------------- /store/user.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 7 | ) 8 | 9 | // Role is the type of a role. 10 | type Role string 11 | 12 | const ( 13 | // RoleAdmin is the ADMIN role. 14 | RoleAdmin Role = "ADMIN" 15 | // RoleUser is the USER role. 16 | RoleUser Role = "USER" 17 | ) 18 | 19 | type User struct { 20 | ID int32 21 | 22 | // Standard fields 23 | CreatedTs int64 24 | UpdatedTs int64 25 | RowStatus storepb.RowStatus 26 | 27 | // Domain specific fields 28 | Email string 29 | Nickname string 30 | PasswordHash string 31 | Role Role 32 | } 33 | 34 | type UpdateUser struct { 35 | ID int32 36 | 37 | RowStatus *storepb.RowStatus 38 | Email *string 39 | Nickname *string 40 | PasswordHash *string 41 | Role *Role 42 | } 43 | 44 | type FindUser struct { 45 | ID *int32 46 | RowStatus *storepb.RowStatus 47 | Email *string 48 | Nickname *string 49 | Role *Role 50 | } 51 | 52 | type DeleteUser struct { 53 | ID int32 54 | } 55 | 56 | func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) { 57 | user, err := s.driver.CreateUser(ctx, create) 58 | if err != nil { 59 | return nil, err 60 | } 61 | s.userCache.Store(user.ID, user) 62 | return user, nil 63 | } 64 | 65 | func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) { 66 | user, err := s.driver.UpdateUser(ctx, update) 67 | if err != nil { 68 | return nil, err 69 | } 70 | s.userCache.Store(user.ID, user) 71 | return user, nil 72 | } 73 | 74 | func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) { 75 | list, err := s.driver.ListUsers(ctx, find) 76 | if err != nil { 77 | return nil, err 78 | } 79 | for _, user := range list { 80 | s.userCache.Store(user.ID, user) 81 | } 82 | return list, nil 83 | } 84 | 85 | func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) { 86 | if find.ID != nil { 87 | if cache, ok := s.userCache.Load(*find.ID); ok { 88 | if user, ok := cache.(*User); ok { 89 | return user, nil 90 | } 91 | } 92 | } 93 | 94 | list, err := s.ListUsers(ctx, find) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | if len(list) == 0 { 100 | return nil, nil 101 | } 102 | 103 | return list[0], nil 104 | } 105 | 106 | func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error { 107 | if err := s.driver.DeleteUser(ctx, delete); err != nil { 108 | return err 109 | } 110 | 111 | s.userCache.Delete(delete.ID) 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /store/user_setting.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | 6 | storepb "github.com/yourselfhosted/slash/proto/gen/store" 7 | ) 8 | 9 | type FindUserSetting struct { 10 | UserID *int32 11 | Key storepb.UserSettingKey 12 | } 13 | 14 | func (s *Store) UpsertUserSetting(ctx context.Context, upsert *storepb.UserSetting) (*storepb.UserSetting, error) { 15 | userSetting, err := s.driver.UpsertUserSetting(ctx, upsert) 16 | if err != nil { 17 | return nil, err 18 | } 19 | s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserId, userSetting.Key.String()), userSetting) 20 | return userSetting, nil 21 | } 22 | 23 | func (s *Store) ListUserSettings(ctx context.Context, find *FindUserSetting) ([]*storepb.UserSetting, error) { 24 | userSettingList, err := s.driver.ListUserSettings(ctx, find) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | for _, userSetting := range userSettingList { 30 | s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserId, userSetting.Key.String()), userSetting) 31 | } 32 | return userSettingList, nil 33 | } 34 | 35 | func (s *Store) GetUserSetting(ctx context.Context, find *FindUserSetting) (*storepb.UserSetting, error) { 36 | if find.UserID != nil && find.Key != storepb.UserSettingKey_USER_SETTING_KEY_UNSPECIFIED { 37 | if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key.String())); ok { 38 | if userSetting, ok := cache.(*storepb.UserSetting); ok { 39 | return userSetting, nil 40 | } 41 | } 42 | } 43 | 44 | list, err := s.ListUserSettings(ctx, find) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | if len(list) == 0 { 50 | return nil, nil 51 | } 52 | 53 | userSetting := list[0] 54 | s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserId, userSetting.Key.String()), userSetting) 55 | return userSetting, nil 56 | } 57 | 58 | // GetUserAccessTokens returns the access tokens of the user. 59 | func (s *Store) GetUserAccessTokens(ctx context.Context, userID int32) ([]*storepb.UserSetting_AccessTokensSetting_AccessToken, error) { 60 | userSetting, err := s.GetUserSetting(ctx, &FindUserSetting{ 61 | UserID: &userID, 62 | Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS, 63 | }) 64 | if err != nil { 65 | return nil, err 66 | } 67 | if userSetting == nil { 68 | return []*storepb.UserSetting_AccessTokensSetting_AccessToken{}, nil 69 | } 70 | 71 | accessTokensUserSetting := userSetting.GetAccessTokens() 72 | return accessTokensUserSetting.AccessTokens, nil 73 | } 74 | --------------------------------------------------------------------------------