mirror of https://github.com/usememos/memos.git
Compare commits
No commits in common. "main" and "v0.14.0" have entirely different histories.
|
|
@ -1,82 +1,31 @@
|
||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: If something isn't working as expected
|
description: Create a report to help us improve
|
||||||
labels: [bug]
|
labels: [bug]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thank you for taking the time to report a bug! Please complete the form below to help us understand and fix the issue.
|
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.
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: pre-check
|
|
||||||
attributes:
|
|
||||||
label: Pre-submission Checklist
|
|
||||||
description: Please confirm you have completed the following steps before submitting
|
|
||||||
options:
|
|
||||||
- label: I have searched the existing issues and this bug has not been reported yet
|
|
||||||
required: true
|
|
||||||
- label: I have tested this issue on the [demo site](https://demo.usememos.com) or the latest version
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: issue-location
|
|
||||||
attributes:
|
|
||||||
label: Where did you encounter this bug?
|
|
||||||
description: Select where you tested and confirmed this issue
|
|
||||||
options:
|
|
||||||
- Latest stable version (self-hosted)
|
|
||||||
- Latest development version (self-hosted)
|
|
||||||
- Demo site (demo.usememos.com)
|
|
||||||
- Older version (please specify below)
|
|
||||||
default: 0
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Memos Version
|
|
||||||
description: Provide the exact version (e.g., `v0.25.2`). Find this in Settings → About or via `--version` flag
|
|
||||||
placeholder: v0.25.2
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: bug-description
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Bug Description
|
label: Describe the bug
|
||||||
description: A clear and concise description of what the bug is
|
description: |
|
||||||
placeholder: When I try to..., the application...
|
Briefly describe the problem you are having in a few paragraphs.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: reproduction-steps
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to Reproduce
|
label: Steps to reproduce
|
||||||
description: Detailed steps to reproduce the behavior
|
description: |
|
||||||
|
Provide the steps to reproduce the issue.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '...'
|
2. Click on '....'
|
||||||
3. Scroll down to '...'
|
3. See error
|
||||||
4. See error
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected-behavior
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected Behavior
|
label: Screenshots or additional context
|
||||||
description: What did you expect to happen?
|
description: |
|
||||||
placeholder: I expected...
|
Add screenshots or any other context about the problem.
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Screenshots & Additional Context
|
|
||||||
description: Add screenshots, browser/OS info, deployment method (Docker/binary), or any other relevant details
|
|
||||||
placeholder: |
|
|
||||||
- Browser: Chrome 120
|
|
||||||
- OS: macOS 14
|
|
||||||
- Deployment: Docker
|
|
||||||
- Database: SQLite
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
blank_issues_enabled: false
|
|
||||||
|
|
@ -1,76 +1,28 @@
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: If you have a suggestion for a new feature
|
description: Suggest an idea for this project
|
||||||
labels: [enhancement]
|
labels: [enhancement]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thank you for suggesting a new feature! Please complete the form below to help us understand your idea.
|
Thanks for taking the time to suggest an idea for Memos!
|
||||||
|
- type: textarea
|
||||||
- type: checkboxes
|
|
||||||
id: pre-check
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Pre-submission Checklist
|
label: Is your feature request related to a problem?
|
||||||
description: Please confirm you have completed the following steps before submitting
|
description: |
|
||||||
options:
|
A clear and concise description of what the problem is.
|
||||||
- label: I have searched the existing issues and this feature has not been requested yet
|
placeholder: |
|
||||||
required: true
|
I'm always frustrated when [...]
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: feature-type
|
|
||||||
attributes:
|
|
||||||
label: Type of Feature
|
|
||||||
description: What type of feature is this?
|
|
||||||
options:
|
|
||||||
- User Interface (UI)
|
|
||||||
- User Experience (UX)
|
|
||||||
- API / Backend
|
|
||||||
- Documentation
|
|
||||||
- Integrations / Plugins
|
|
||||||
- Security / Privacy
|
|
||||||
- Performance
|
|
||||||
- Other
|
|
||||||
default: 0
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem-statement
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Problem or Use Case
|
label: Describe the solution you'd like
|
||||||
description: What problem does this feature solve? What are you trying to accomplish?
|
description: |
|
||||||
placeholder: |
|
A clear and concise description of what you want to happen.
|
||||||
I often need to... but currently there's no way to...
|
|
||||||
This would help me/users to...
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: proposed-solution
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Proposed Solution
|
label: Additional context
|
||||||
description: A clear and concise description of what you want to happen
|
description: Add any other context or screenshots about the feature request.
|
||||||
placeholder: |
|
|
||||||
It would be great if Memos could...
|
|
||||||
For example, a button/feature that...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Alternatives Considered
|
|
||||||
description: Have you considered any alternative solutions or workarounds?
|
|
||||||
placeholder: |
|
|
||||||
I've tried... but it doesn't work well because...
|
|
||||||
An alternative could be...
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Add any other context, mockups, screenshots, or examples about the feature request
|
|
||||||
placeholder: |
|
|
||||||
- Similar feature in other apps: ...
|
|
||||||
- Mockups or screenshots: ...
|
|
||||||
- Related discussions: ...
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,39 @@
|
||||||
name: Backend Tests
|
name: Backend Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
- "release/*.*.*"
|
||||||
- "go.mod"
|
|
||||||
- "go.sum"
|
|
||||||
- "**.go"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
go-static-checks:
|
go-static-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.19
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Verify go.mod is tidy
|
- name: Verify go.mod is tidy
|
||||||
run: |
|
run: |
|
||||||
go mod tidy -go=1.25
|
go mod tidy -go=1.19
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
with:
|
||||||
version: v2.4.0
|
version: v1.52.0
|
||||||
args: --verbose --timeout=3m
|
args: -v --timeout=3m
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|
||||||
go-tests:
|
go-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.19
|
||||||
check-latest: true
|
check-latest: true
|
||||||
cache: true
|
cache: true
|
||||||
- name: Run all tests
|
- name: Run all tests
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
name: Build and Push Canary Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKER_PLATFORMS: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm64
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.repository }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push-canary-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
install: true
|
|
||||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
neosmemo/memos
|
|
||||||
ghcr.io/usememos/memos
|
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
tags: |
|
|
||||||
type=raw,value=canary
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ github.token }}
|
|
||||||
|
|
||||||
# Frontend build.
|
|
||||||
- uses: pnpm/action-setup@v4.1.0
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- uses: actions/setup-node@v5
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
|
||||||
- run: pnpm install
|
|
||||||
working-directory: web
|
|
||||||
- name: Run frontend build
|
|
||||||
run: pnpm release
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
- name: Build and Push
|
|
||||||
id: docker_build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./scripts/Dockerfile
|
|
||||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
build-args: |
|
|
||||||
BUILDKIT_INLINE_CACHE=1
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
name: build-and-push-release-image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
# Run on pushing branches like `release/1.0.0`
|
||||||
|
- "release/*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-release-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Extract build args
|
||||||
|
# Extract version from branch name
|
||||||
|
# Example: branch name `release/1.0.0` sets up env.VERSION=1.0.0
|
||||||
|
run: |
|
||||||
|
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: neosmemo
|
||||||
|
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
install: true
|
||||||
|
version: v0.9.1
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
neosmemo/memos
|
||||||
|
ghcr.io/usememos/memos
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }}
|
||||||
|
type=semver,pattern={{major}},value=${{ env.VERSION }}
|
||||||
|
|
||||||
|
- name: Build and Push
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
name: Build and Push Stable Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "release/**"
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKER_PLATFORMS: |
|
|
||||||
linux/amd64
|
|
||||||
linux/arm/v7
|
|
||||||
linux/arm64
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
install: true
|
|
||||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
|
||||||
|
|
||||||
- name: Extract version
|
|
||||||
run: |
|
|
||||||
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
|
|
||||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
neosmemo/memos
|
|
||||||
ghcr.io/usememos/memos
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}},value=${{ env.VERSION }}
|
|
||||||
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }}
|
|
||||||
type=raw,value=stable
|
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.version=${{ env.VERSION }}
|
|
||||||
|
|
||||||
# Frontend build.
|
|
||||||
- uses: pnpm/action-setup@v4.1.0
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- uses: actions/setup-node@v5
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
|
||||||
- run: pnpm install
|
|
||||||
working-directory: web
|
|
||||||
- name: Run frontend build
|
|
||||||
run: pnpm release
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
- name: Build and Push
|
|
||||||
id: docker_build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./scripts/Dockerfile
|
|
||||||
platforms: ${{ env.DOCKER_PLATFORMS }}
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
build-args: |
|
|
||||||
BUILDKIT_INLINE_CACHE=1
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
name: build-and-push-test-image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-test-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: neosmemo
|
||||||
|
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
install: true
|
||||||
|
version: v0.9.1
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
neosmemo/memos
|
||||||
|
ghcr.io/usememos/memos
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
|
tags: |
|
||||||
|
type=raw,value=test
|
||||||
|
|
||||||
|
- name: Build and Push
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
name: build-artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
# Run on pushing branches like `release/1.0.0`
|
||||||
|
- "release/*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-artifacts:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
goarch: [amd64, arm64]
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
cgo_env: CC=x86_64-w64-mingw32-gcc
|
||||||
|
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Clone Memos
|
||||||
|
run: git clone https://github.com/usememos/memos.git
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: "18"
|
||||||
|
|
||||||
|
- name: Build frontend (Windows)
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
cd memos/web
|
||||||
|
npm install -g pnpm
|
||||||
|
pnpm i --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
Remove-Item -Path ../server/dist -Recurse -Force
|
||||||
|
mv dist ../server/
|
||||||
|
|
||||||
|
- name: Build frontend (non-Windows)
|
||||||
|
if: matrix.os != 'windows-latest'
|
||||||
|
run: |
|
||||||
|
cd memos/web
|
||||||
|
npm install -g pnpm
|
||||||
|
pnpm i --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
rm -rf ../server/dist
|
||||||
|
mv dist ../server/
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: 1.19
|
||||||
|
|
||||||
|
- name: Install mingw-w64 (Windows)
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
run: |
|
||||||
|
choco install mingw
|
||||||
|
echo ${{ matrix.cgo_env }} >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Install gcc-aarch64-linux-gnu (Ubuntu ARM64)
|
||||||
|
if: matrix.os == 'ubuntu-latest' && matrix.goarch == 'arm64'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||||
|
echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: |
|
||||||
|
cd memos
|
||||||
|
go build -o memos-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} ./main.go
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: memos-binary-${{ matrix.os }}-${{ matrix.goarch }}
|
||||||
|
path: memos/memos-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.os == 'windows-latest' && '.exe' || '' }}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
# The branches below must be a subset of the branches above
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: ["go", "javascript"]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v1
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
|
|
||||||
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v1
|
||||||
|
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 https://git.io/JvXDl
|
||||||
|
|
||||||
|
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||||
|
# and modify them (or add more) to build your code if your project
|
||||||
|
# uses a compiled language
|
||||||
|
|
||||||
|
#- run: |
|
||||||
|
# make bootstrap
|
||||||
|
# make release
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v1
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
name: Demo Render Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-demo:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Trigger Render Deploy
|
|
||||||
run: |
|
|
||||||
curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"trigger": "github_action"}'
|
|
||||||
|
|
||||||
- name: Deployment Status
|
|
||||||
run: echo "Demo deployment triggered successfully on Render"
|
|
||||||
|
|
@ -1,43 +1,40 @@
|
||||||
name: Frontend Tests
|
name: Frontend Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
- "release/*.*.*"
|
||||||
- "web/**"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
static-checks:
|
eslint-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v4.1.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 8
|
||||||
- uses: actions/setup-node@v5
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
cache-dependency-path: "web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
working-directory: web
|
working-directory: web
|
||||||
- name: Run check
|
- name: Run eslint check
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
working-directory: web
|
working-directory: web
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v4.1.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 8
|
||||||
- uses: actions/setup-node@v5
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "18"
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "web/pnpm-lock.yaml"
|
cache-dependency-path: "web/pnpm-lock.yaml"
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
name: 'issue-translator'
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: usthe/issues-translate-action@v2.7
|
||||||
|
with:
|
||||||
|
IS_MODIFY_TITLE: false
|
||||||
|
# not require, default false, . Decide whether to modify the issue title
|
||||||
|
# if true, the robot account @Issues-translate-bot must have modification permissions, invite @Issues-translate-bot to your project or use your custom bot.
|
||||||
|
CUSTOM_BOT_NOTE: Issue is not in English. It has been translated automatically.
|
||||||
|
# not require. Customize the translation robot prefix message.
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
name: Proto Linter
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "proto/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-protos:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Setup buf
|
|
||||||
uses: bufbuild/buf-setup-action@v1
|
|
||||||
with:
|
|
||||||
github_token: ${{ github.token }}
|
|
||||||
- name: buf lint
|
|
||||||
uses: bufbuild/buf-lint-action@v1
|
|
||||||
with:
|
|
||||||
input: "proto"
|
|
||||||
- name: buf format
|
|
||||||
run: |
|
|
||||||
if [[ $(buf format -d) ]]; then
|
|
||||||
echo "Run 'buf format -d'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
name: Stale
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 */8 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/stale@v10.0.0
|
|
||||||
with:
|
|
||||||
days-before-issue-stale: 14
|
|
||||||
days-before-issue-close: 7
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
name: Build PR Image
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, closed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-memos:
|
||||||
|
name: Build and push `Memos`
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
if: ${{ github.event.action != 'closed' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout git repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Generate UUID image name
|
||||||
|
id: uuid
|
||||||
|
run: echo "UUID_WORKER=$(uuidgen)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: registry.uffizzi.com/${{ env.UUID_WORKER }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=60d
|
||||||
|
|
||||||
|
- name: Build and Push Image to registry.uffizzi.com - Uffizzi's ephemeral Registry
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: Dockerfile
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
push: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha, mode=max
|
||||||
|
|
||||||
|
render-compose-file:
|
||||||
|
name: Render Docker Compose File
|
||||||
|
# Pass output of this workflow to another triggered by `workflow_run` event.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- build-memos
|
||||||
|
outputs:
|
||||||
|
compose-file-cache-key: ${{ steps.hash.outputs.hash }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout git repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Render Compose File
|
||||||
|
run: |
|
||||||
|
MEMOS_IMAGE=${{ needs.build-memos.outputs.tags }}
|
||||||
|
export MEMOS_IMAGE
|
||||||
|
# Render simple template from environment variables.
|
||||||
|
envsubst < docker-compose.uffizzi.yml > docker-compose.rendered.yml
|
||||||
|
cat docker-compose.rendered.yml
|
||||||
|
- name: Upload Rendered Compose File as Artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: preview-spec
|
||||||
|
path: docker-compose.rendered.yml
|
||||||
|
retention-days: 2
|
||||||
|
- name: Upload PR Event as Artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: preview-spec
|
||||||
|
path: ${{github.event_path}}
|
||||||
|
retention-days: 2
|
||||||
|
|
||||||
|
delete-preview:
|
||||||
|
name: Call for Preview Deletion
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.action == 'closed' }}
|
||||||
|
steps:
|
||||||
|
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
|
||||||
|
- name: Upload PR Event as Artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: preview-spec
|
||||||
|
path: ${{github.event_path}}
|
||||||
|
retention-days: 2
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
name: Deploy Uffizzi Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- "Build PR Image"
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cache-compose-file:
|
||||||
|
name: Cache Compose File
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
compose-file-cache-key: ${{ env.HASH }}
|
||||||
|
pr-number: ${{ env.PR_NUMBER }}
|
||||||
|
steps:
|
||||||
|
- name: 'Download artifacts'
|
||||||
|
# Fetch output (zip archive) from the workflow run that triggered this workflow.
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: context.payload.workflow_run.id,
|
||||||
|
});
|
||||||
|
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
|
||||||
|
return artifact.name == "preview-spec"
|
||||||
|
})[0];
|
||||||
|
let download = await github.rest.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: matchArtifact.id,
|
||||||
|
archive_format: 'zip',
|
||||||
|
});
|
||||||
|
let fs = require('fs');
|
||||||
|
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
|
||||||
|
|
||||||
|
- name: 'Unzip artifact'
|
||||||
|
run: unzip preview-spec.zip
|
||||||
|
- name: Read Event into ENV
|
||||||
|
run: |
|
||||||
|
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
|
||||||
|
cat event.json >> $GITHUB_ENV
|
||||||
|
echo -e '\nEOF' >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Hash Rendered Compose File
|
||||||
|
id: hash
|
||||||
|
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
|
||||||
|
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||||
|
run: echo "HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
|
||||||
|
- name: Cache Rendered Compose File
|
||||||
|
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: docker-compose.rendered.yml
|
||||||
|
key: ${{ env.HASH }}
|
||||||
|
|
||||||
|
- name: Read PR Number From Event Object
|
||||||
|
id: pr
|
||||||
|
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
|
||||||
|
- name: DEBUG - Print Job Outputs
|
||||||
|
if: ${{ runner.debug }}
|
||||||
|
run: |
|
||||||
|
echo "PR number: ${{ env.PR_NUMBER }}"
|
||||||
|
echo "Compose file hash: ${{ env.HASH }}"
|
||||||
|
cat event.json
|
||||||
|
|
||||||
|
deploy-uffizzi-preview:
|
||||||
|
name: Use Remote Workflow to Preview on Uffizzi
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
needs:
|
||||||
|
- cache-compose-file
|
||||||
|
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2
|
||||||
|
with:
|
||||||
|
# If this workflow was triggered by a PR close event, cache-key will be an empty string
|
||||||
|
# and this reusable workflow will delete the preview deployment.
|
||||||
|
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
|
||||||
|
compose-file-cache-path: docker-compose.rendered.yml
|
||||||
|
server: https://app.uffizzi.com
|
||||||
|
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
id-token: write
|
||||||
|
|
@ -1,26 +1,18 @@
|
||||||
|
# Air (hot reload) generated
|
||||||
|
.air
|
||||||
|
|
||||||
# temp folder
|
# temp folder
|
||||||
tmp
|
tmp
|
||||||
|
|
||||||
# Frontend asset
|
# Frontend asset
|
||||||
web/dist
|
web/dist
|
||||||
|
|
||||||
# Build artifacts
|
# build folder
|
||||||
build/
|
build
|
||||||
bin/
|
|
||||||
memos
|
|
||||||
|
|
||||||
# Plan/design documents
|
|
||||||
docs/plans/
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Jetbrains
|
# Jetbrains
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
# Docker Compose Environment File
|
bin/air
|
||||||
.env
|
|
||||||
|
|
||||||
dist
|
|
||||||
|
|
||||||
# VSCode settings
|
|
||||||
.vscode
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
version: "2"
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
|
- goimports
|
||||||
- revive
|
- revive
|
||||||
- govet
|
- govet
|
||||||
- staticcheck
|
- staticcheck
|
||||||
|
|
@ -11,26 +10,17 @@ linters:
|
||||||
- rowserrcheck
|
- rowserrcheck
|
||||||
- nilerr
|
- nilerr
|
||||||
- godot
|
- godot
|
||||||
- forbidigo
|
|
||||||
- mirror
|
issues:
|
||||||
- bodyclose
|
exclude:
|
||||||
disable:
|
- Rollback
|
||||||
- errcheck
|
- fmt.Printf
|
||||||
settings:
|
- fmt.Print
|
||||||
exhaustive:
|
|
||||||
explicit-exhaustive-switch: false
|
linters-settings:
|
||||||
staticcheck:
|
|
||||||
checks:
|
|
||||||
- all
|
|
||||||
- -ST1000
|
|
||||||
- -ST1003
|
|
||||||
- -ST1021
|
|
||||||
- -QF1003
|
|
||||||
revive:
|
revive:
|
||||||
# Default to run all linters so that new rules in the future could automatically be added to the static check.
|
|
||||||
enable-all-rules: true
|
enable-all-rules: true
|
||||||
rules:
|
rules:
|
||||||
# The following rules are too strict and make coding harder. We do not enable them for now.
|
|
||||||
- name: file-header
|
- name: file-header
|
||||||
disabled: true
|
disabled: true
|
||||||
- name: line-length-limit
|
- name: line-length-limit
|
||||||
|
|
@ -61,41 +51,14 @@ linters:
|
||||||
disabled: true
|
disabled: true
|
||||||
- name: early-return
|
- name: early-return
|
||||||
disabled: true
|
disabled: true
|
||||||
- name: use-any
|
|
||||||
disabled: true
|
|
||||||
- name: exported
|
|
||||||
disabled: true
|
|
||||||
- name: unhandled-error
|
|
||||||
disabled: true
|
|
||||||
- name: if-return
|
|
||||||
disabled: true
|
|
||||||
- name: max-control-nesting
|
|
||||||
disabled: true
|
|
||||||
- name: redefines-builtin-id
|
|
||||||
disabled: true
|
|
||||||
- name: package-comments
|
|
||||||
disabled: true
|
|
||||||
gocritic:
|
gocritic:
|
||||||
disabled-checks:
|
disabled-checks:
|
||||||
- ifElseChain
|
- ifElseChain
|
||||||
govet:
|
govet:
|
||||||
settings:
|
settings:
|
||||||
printf: # The name of the analyzer, run `go tool vet help` to see the list of all analyzers
|
printf:
|
||||||
funcs: # Run `go tool vet help printf` to see the full configuration of `printf`.
|
funcs:
|
||||||
- common.Errorf
|
- common.Errorf
|
||||||
enable-all: true
|
|
||||||
disable:
|
|
||||||
- fieldalignment
|
|
||||||
- shadow
|
|
||||||
forbidigo:
|
forbidigo:
|
||||||
forbid:
|
forbid:
|
||||||
- pattern: 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
|
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
|
||||||
- pattern: 'ioutil\.ReadDir(# Please use os\.ReadDir)?'
|
|
||||||
|
|
||||||
formatters:
|
|
||||||
enable:
|
|
||||||
- goimports
|
|
||||||
settings:
|
|
||||||
goimports:
|
|
||||||
local-prefixes:
|
|
||||||
- github.com/usememos/memos
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["golang.go"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"path": "../"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"path": "../web"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"json.schemaDownload.enable":true,
|
||||||
|
"go.lintOnSave": "workspace",
|
||||||
|
"go.lintTool": "golangci-lint",
|
||||||
|
"go.inferGopath": false,
|
||||||
|
"go.toolsEnvVars": {
|
||||||
|
"GO111MODULE": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
CLAUDE.md
83
CLAUDE.md
|
|
@ -1,83 +0,0 @@
|
||||||
# Memos Codebase Guide
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Memos is a self-hosted knowledge management platform with a Go backend and React frontend. The architecture uses gRPC for internal communication with REST access via gRPC-Gateway.
|
|
||||||
|
|
||||||
## Architecture Decision Context
|
|
||||||
|
|
||||||
**Why gRPC + gRPC-Gateway?**
|
|
||||||
- Native gRPC for performance, REST API for compatibility
|
|
||||||
- Both protocols served on same port via `cmux` connection multiplexer
|
|
||||||
- Frontend uses gRPC-Web (`nice-grpc-web`) for type-safe API calls
|
|
||||||
|
|
||||||
**Why multi-database support?**
|
|
||||||
- Store interface (`store/driver.go`) abstracts persistence
|
|
||||||
- Three implementations: SQLite (default), MySQL, PostgreSQL
|
|
||||||
- Each driver has its own migration files in `store/db/{driver}/migration/`
|
|
||||||
- Schema version tracked in `instance_setting` table (key: `bb.general.version`)
|
|
||||||
|
|
||||||
**Why MobX for frontend state?**
|
|
||||||
- Simpler than Redux for this application's needs
|
|
||||||
- Stores in `web/src/store/` handle global state (user, memos, editor, dialogs)
|
|
||||||
|
|
||||||
## Critical Development Commands
|
|
||||||
|
|
||||||
**Backend:**
|
|
||||||
```bash
|
|
||||||
go run ./cmd/memos --mode dev --port 8081 # Start dev server
|
|
||||||
go test ./... # Run tests
|
|
||||||
golangci-lint run # Lint
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend:**
|
|
||||||
```bash
|
|
||||||
cd web && pnpm dev # Start dev server
|
|
||||||
cd web && pnpm lint:fix # Lint and fix
|
|
||||||
cd web && pnpm release # Build and copy to backend
|
|
||||||
```
|
|
||||||
|
|
||||||
**Protocol Buffers:**
|
|
||||||
```bash
|
|
||||||
cd proto && buf generate # Regenerate Go + TypeScript from .proto
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Workflows
|
|
||||||
|
|
||||||
**Modifying APIs:**
|
|
||||||
1. Edit `.proto` files in `proto/api/v1/`
|
|
||||||
2. Run `buf generate` to regenerate code
|
|
||||||
3. Implement in `server/router/api/v1/`
|
|
||||||
4. Frontend types auto-update in `web/src/types/proto/`
|
|
||||||
|
|
||||||
**Database Schema Changes:**
|
|
||||||
1. Create migration files: `store/migration/{sqlite,mysql,postgres}/{version}/NN__description.sql`
|
|
||||||
2. Update `LATEST.sql` in each driver directory
|
|
||||||
3. Schema version auto-determined from migration files
|
|
||||||
4. If adding new tables/models, also update `store/driver.go` interface and implementations
|
|
||||||
|
|
||||||
**Authentication Flow:**
|
|
||||||
- Interceptor runs on all gRPC methods (`server/router/api/v1/acl.go`)
|
|
||||||
- Public endpoints listed in `acl_config.go`
|
|
||||||
- Supports both session cookies and JWT bearer tokens
|
|
||||||
|
|
||||||
## Critical Path Components
|
|
||||||
|
|
||||||
**Entry point:** `cmd/memos/` starts the server
|
|
||||||
**API layer:** `server/router/api/v1/` implements gRPC services
|
|
||||||
**Data layer:** `store/` handles all persistence
|
|
||||||
**Frontend:** `web/src/` React app with MobX state management
|
|
||||||
|
|
||||||
## Testing Expectations
|
|
||||||
|
|
||||||
Go tests are required for store and API changes. Frontend relies on TypeScript checking and manual validation.
|
|
||||||
|
|
||||||
Run `go test ./store/...` and `go test ./server/router/api/v1/test/...` before committing backend changes.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Backend accepts flags or `MEMOS_*` environment variables:
|
|
||||||
- `--mode` / `MEMOS_MODE`: `dev`, `prod`, `demo`
|
|
||||||
- `--port` / `MEMOS_PORT`: HTTP/gRPC port (default: 5230)
|
|
||||||
- `--data` / `MEMOS_DATA`: Data directory (default: ~/.memos)
|
|
||||||
- `--driver` / `MEMOS_DRIVER`: `sqlite`, `mysql`, `postgres`
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Build frontend dist.
|
||||||
|
FROM node:18.12.1-alpine3.16 AS frontend
|
||||||
|
WORKDIR /frontend-build
|
||||||
|
|
||||||
|
COPY ./web/package.json ./web/pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
RUN corepack enable && pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
COPY ./web/ .
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Build backend exec file.
|
||||||
|
FROM golang:1.19.3-alpine3.16 AS backend
|
||||||
|
WORKDIR /backend-build
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /frontend-build/dist ./server/dist
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 go build -o memos ./main.go
|
||||||
|
|
||||||
|
# Make workspace with above generated files.
|
||||||
|
FROM alpine:3.16 AS monolithic
|
||||||
|
WORKDIR /usr/local/memos
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
ENV TZ="UTC"
|
||||||
|
|
||||||
|
COPY --from=backend /backend-build/memos /usr/local/memos/
|
||||||
|
|
||||||
|
EXPOSE 5230
|
||||||
|
|
||||||
|
# Directory to store the data, which can be referenced as the mounting point.
|
||||||
|
RUN mkdir -p /var/opt/memos
|
||||||
|
VOLUME /var/opt/memos
|
||||||
|
|
||||||
|
ENV MEMOS_MODE="prod"
|
||||||
|
ENV MEMOS_PORT="5230"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./memos"]
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Memos
|
Copyright (c) 2022 Memos
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
159
README.md
159
README.md
|
|
@ -1,135 +1,64 @@
|
||||||
# Memos
|
# memos
|
||||||
|
|
||||||
<img align="right" height="96px" src="https://raw.githubusercontent.com/usememos/.github/refs/heads/main/assets/logo-rounded.png" alt="Memos" />
|
<img height="72px" src="https://usememos.com/logo.webp" alt="✍️ memos" align="right" />
|
||||||
|
|
||||||
An open-source, self-hosted note-taking service. Your thoughts, your data, your control — no tracking, no ads, no subscription fees.
|
A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.
|
||||||
|
|
||||||
[](https://www.usememos.com)
|
<a href="https://usememos.com/docs">Documentation</a> •
|
||||||
[](https://demo.usememos.com/)
|
<a href="https://demo.usememos.com/">Live Demo</a> •
|
||||||
[](https://www.usememos.com/docs)
|
Discuss in <a href="https://discord.gg/tfPJa4UmAv">Discord</a> / <a href="https://t.me/+-_tNF1k70UU4ZTc9">Telegram</a>
|
||||||
[](https://discord.gg/tfPJa4UmAv)
|
|
||||||
[](https://hub.docker.com/r/neosmemo/memos)
|
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/usememos/.github/refs/heads/main/assets/demo.png" alt="Memos Demo Screenshot" height="512" />
|
<p>
|
||||||
|
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos?logo=github" /></a>
|
||||||
|
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
### 💎 Featured Sponsors
|

|
||||||
|
|
||||||
[**Warp** — The AI-powered terminal built for speed and collaboration](https://go.warp.dev/memos)
|
## Key points
|
||||||
|
|
||||||
<a href="https://go.warp.dev/memos" target="_blank" rel="noopener">
|
- **Open source and free forever**. Embrace a future where creativity knows no boundaries with our open-source solution – free today, tomorrow, and always.
|
||||||
<img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/main/Github/Sponsor/Warp-Github-LG-02.png" alt="Warp - The AI-powered terminal built for speed and collaboration" width="512" />
|
- **Self-hosting with Docker in just seconds**. Enjoy the flexibility, scalability, and ease of setup that Docker provides, allowing you to have full control over your data and privacy.
|
||||||
</a>
|
- **Pure text with added Markdown support.** Say goodbye to the overwhelming mental burden of rich formatting and embrace a minimalist approach.
|
||||||
|
- **Customize and share your notes effortlessly**. With our intuitive sharing features, you can easily collaborate and distribute your notes with others.
|
||||||
|
- **RESTful API for third-party services.** Embrace the power of integration and unleash new possibilities with our RESTful API support.
|
||||||
|
|
||||||
---
|
## Deploy with Docker in seconds
|
||||||
|
|
||||||
[**LambdaTest** - Cross-browser testing cloud](https://www.lambdatest.com/?utm_source=memos&utm_medium=sponsor)
|
|
||||||
|
|
||||||
<a href="https://www.lambdatest.com/?utm_source=memos&utm_medium=sponsor" target="_blank" rel="noopener">
|
|
||||||
<img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest - Cross-browser testing cloud" height="50" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Memos is a privacy-first, self-hosted knowledge base that works seamlessly for personal notes, team wikis, and knowledge management. Built with Go and React, it offers lightning-fast performance without compromising on features or usability.
|
|
||||||
|
|
||||||
**Why choose Memos over cloud services?**
|
|
||||||
|
|
||||||
| Feature | Memos | Cloud Services |
|
|
||||||
| ----------------- | ------------------------------ | ----------------------------- |
|
|
||||||
| **Privacy** | ✅ Self-hosted, zero telemetry | ❌ Your data on their servers |
|
|
||||||
| **Cost** | ✅ Free forever, MIT license | ❌ Subscription fees |
|
|
||||||
| **Performance** | ✅ Instant load, no latency | ⚠️ Depends on internet |
|
|
||||||
| **Ownership** | ✅ Full control & export | ❌ Vendor lock-in |
|
|
||||||
| **API Access** | ✅ Full REST + gRPC APIs | ⚠️ Limited or paid |
|
|
||||||
| **Customization** | ✅ Open source, forkable | ❌ Closed ecosystem |
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **🔒 Privacy-First Architecture**
|
|
||||||
|
|
||||||
- Self-hosted on your infrastructure with zero telemetry
|
|
||||||
- Complete data ownership and export capabilities
|
|
||||||
- No tracking, no ads, no vendor lock-in
|
|
||||||
|
|
||||||
- **📝 Markdown Native**
|
|
||||||
|
|
||||||
- Full markdown support
|
|
||||||
- Plain text storage — take your data anywhere
|
|
||||||
|
|
||||||
- **⚡ Blazing Fast**
|
|
||||||
|
|
||||||
- Built with Go backend and React frontend
|
|
||||||
- Optimized for performance at any scale
|
|
||||||
|
|
||||||
- **🐳 Simple Deployment**
|
|
||||||
|
|
||||||
- One-line Docker installation
|
|
||||||
- Supports SQLite, MySQL, and PostgreSQL
|
|
||||||
|
|
||||||
- **🔗 Developer-Friendly**
|
|
||||||
|
|
||||||
- Full REST and gRPC APIs
|
|
||||||
- Easy integration with existing workflows
|
|
||||||
|
|
||||||
- **🎨 Beautiful Interface**
|
|
||||||
- Clean, minimal design and dark mode support
|
|
||||||
- Mobile-responsive layout
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Docker (Recommended)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos ghcr.io/usememos/memos:latest
|
||||||
--name memos \
|
|
||||||
-p 5230:5230 \
|
|
||||||
-v ~/.memos:/var/opt/memos \
|
|
||||||
neosmemo/memos:stable
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5230` and start writing!
|
> The `~/.memos/` directory will be used as the data directory on your local machine, while `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
|
||||||
|
|
||||||
### Try the Live Demo
|
Learn more about [other installation methods](https://usememos.com/docs#installation).
|
||||||
|
|
||||||
Don't want to install yet? Try our [live demo](https://demo.usememos.com/) first!
|
## Contribution
|
||||||
|
|
||||||
### Other Installation Methods
|
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. We greatly appreciate any contributions you make. Thank you for being a part of our community! 🥰
|
||||||
|
|
||||||
- **Docker Compose** - Recommended for production deployments
|
<a href="https://github.com/usememos/memos/graphs/contributors">
|
||||||
- **Pre-built Binaries** - Available for Linux, macOS, and Windows
|
<img src="https://contrib.rocks/image?repo=usememos/memos" />
|
||||||
- **Kubernetes** - Helm charts and manifests available
|
</a>
|
||||||
- **Build from Source** - For development and customization
|
|
||||||
|
|
||||||
See our [installation guide](https://www.usememos.com/docs/installation) for detailed instructions.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
We welcome contributions of all kinds! Whether you're fixing bugs, adding features, improving documentation, or helping with translations — every contribution matters.
|
|
||||||
|
|
||||||
**Ways to contribute:**
|
|
||||||
|
|
||||||
- 🐛 [Report bugs](https://github.com/usememos/memos/issues/new?template=bug_report.md)
|
|
||||||
- 💡 [Suggest features](https://github.com/usememos/memos/issues/new?template=feature_request.md)
|
|
||||||
- 🔧 [Submit pull requests](https://github.com/usememos/memos/pulls)
|
|
||||||
- 📖 [Improve documentation](https://github.com/usememos/memos/tree/main/docs)
|
|
||||||
- 🌍 [Help with translations](https://github.com/usememos/memos/tree/main/web/src/locales)
|
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
Love Memos? [Sponsor us on GitHub](https://github.com/sponsors/usememos) to help keep the project growing!
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://star-history.com/#usememos/memos&Date)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Memos is open-source software licensed under the [MIT License](LICENSE).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**[Website](https://www.usememos.com)** • **[Documentation](https://www.usememos.com/docs)** • **[Demo](https://demo.usememos.com/)** • **[Discord](https://discord.gg/tfPJa4UmAv)** • **[X/Twitter](https://x.com/usememos)**
|
- [Moe Memos](https://memos.moe/) - Third party client for iOS and Android
|
||||||
|
- [lmm214/memos-bber](https://github.com/lmm214/memos-bber) - Chrome extension
|
||||||
|
- [Rabithua/memos_wmp](https://github.com/Rabithua/memos_wmp) - WeChat MiniProgram
|
||||||
|
- [qazxcdswe123/telegramMemoBot](https://github.com/qazxcdswe123/telegramMemoBot) - Telegram bot
|
||||||
|
- [eallion/memos.top](https://github.com/eallion/memos.top) - Static page rendered with the Memos API
|
||||||
|
- [eindex/logseq-memos-sync](https://github.com/EINDEX/logseq-memos-sync) - Logseq plugin
|
||||||
|
- [JakeLaoyu/memos-import-from-flomo](https://github.com/JakeLaoyu/memos-import-from-flomo) - Import data. Support from flomo, wechat reading
|
||||||
|
- [Send to memos](https://sharecuts.cn/shortcut/12640) - A shortcut for iOS
|
||||||
|
- [Memos Raycast Extension](https://www.raycast.com/JakeYu/memos) - Raycast extension
|
||||||
|
- [Memos Desktop](https://github.com/xudaolong/memos-desktop) - Third party client for MacOS and Windows
|
||||||
|
- [MemosGallery](https://github.com/BarryYangi/MemosGallery) - A static Gallery rendered with the Memos API
|
||||||
|
|
||||||
<a href="https://vercel.com/oss">
|
## Acknowledgements
|
||||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
|
||||||
</a>
|
- Thanks [Uffizzi](https://www.uffizzi.com/) for sponsoring preview environments for PRs.
|
||||||
|
|
||||||
|
## Star history
|
||||||
|
|
||||||
|
[](https://star-history.com/#usememos/memos&Date)
|
||||||
|
|
|
||||||
45
SECURITY.md
45
SECURITY.md
|
|
@ -1,46 +1,7 @@
|
||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
## Project Status
|
## Reporting a bug
|
||||||
|
|
||||||
Memos is currently in beta (v0.x). While we take security seriously, we are not yet ready for formal CVE assignments or coordinated disclosure programs.
|
Report security bugs via GitHub [issues](https://github.com/usememos/memos/issues).
|
||||||
|
|
||||||
## Reporting Security Issues
|
For more information, please contact [stevenlgtm@gmail.com](stevenlgtm@gmail.com).
|
||||||
|
|
||||||
### For All Security Concerns:
|
|
||||||
Please report via **email only**: usememos@gmail.com
|
|
||||||
|
|
||||||
**DO NOT open public GitHub issues for security vulnerabilities.**
|
|
||||||
|
|
||||||
Include in your report:
|
|
||||||
- Description of the issue
|
|
||||||
- Steps to reproduce
|
|
||||||
- Affected versions
|
|
||||||
- Your assessment of severity
|
|
||||||
|
|
||||||
### What to Expect:
|
|
||||||
- We will acknowledge your report as soon as we can
|
|
||||||
- Fixes will be included in regular releases without special security advisories
|
|
||||||
- No CVEs will be assigned during the beta phase
|
|
||||||
- Credit will be given in release notes if you wish
|
|
||||||
|
|
||||||
### For Non-Security Bugs:
|
|
||||||
Use GitHub issues for functionality bugs, feature requests, and general questions.
|
|
||||||
|
|
||||||
## Philosophy
|
|
||||||
|
|
||||||
As a beta project, we prioritize:
|
|
||||||
1. **Rapid iteration** over lengthy disclosure timelines
|
|
||||||
2. **Quick patches** over formal security processes
|
|
||||||
3. **Transparency** about our beta status
|
|
||||||
|
|
||||||
We plan to implement formal vulnerability disclosure and CVE handling after reaching v1.0 stable.
|
|
||||||
|
|
||||||
## Self-Hosting Security
|
|
||||||
|
|
||||||
Since Memos is self-hosted software:
|
|
||||||
- Keep your instance updated to the latest release
|
|
||||||
- Don't expose your instance directly to the internet without authentication
|
|
||||||
- Use reverse proxies (nginx, Caddy) with rate limiting
|
|
||||||
- Review the deployment documentation for security best practices
|
|
||||||
|
|
||||||
Thank you for helping improve Memos!
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import "github.com/usememos/memos/server/profile"
|
||||||
|
|
||||||
|
// ActivityType is the type for an activity.
|
||||||
|
type ActivityType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// User related.
|
||||||
|
|
||||||
|
// ActivityUserCreate is the type for creating users.
|
||||||
|
ActivityUserCreate ActivityType = "user.create"
|
||||||
|
// ActivityUserUpdate is the type for updating users.
|
||||||
|
ActivityUserUpdate ActivityType = "user.update"
|
||||||
|
// ActivityUserDelete is the type for deleting users.
|
||||||
|
ActivityUserDelete ActivityType = "user.delete"
|
||||||
|
// ActivityUserAuthSignIn is the type for user signin.
|
||||||
|
ActivityUserAuthSignIn ActivityType = "user.auth.signin"
|
||||||
|
// ActivityUserAuthSignUp is the type for user signup.
|
||||||
|
ActivityUserAuthSignUp ActivityType = "user.auth.signup"
|
||||||
|
// ActivityUserSettingUpdate is the type for updating user settings.
|
||||||
|
ActivityUserSettingUpdate ActivityType = "user.setting.update"
|
||||||
|
|
||||||
|
// Memo related.
|
||||||
|
|
||||||
|
// ActivityMemoCreate is the type for creating memos.
|
||||||
|
ActivityMemoCreate ActivityType = "memo.create"
|
||||||
|
// ActivityMemoUpdate is the type for updating memos.
|
||||||
|
ActivityMemoUpdate ActivityType = "memo.update"
|
||||||
|
// ActivityMemoDelete is the type for deleting memos.
|
||||||
|
ActivityMemoDelete ActivityType = "memo.delete"
|
||||||
|
|
||||||
|
// Shortcut related.
|
||||||
|
|
||||||
|
// ActivityShortcutCreate is the type for creating shortcuts.
|
||||||
|
ActivityShortcutCreate ActivityType = "shortcut.create"
|
||||||
|
// ActivityShortcutUpdate is the type for updating shortcuts.
|
||||||
|
ActivityShortcutUpdate ActivityType = "shortcut.update"
|
||||||
|
// ActivityShortcutDelete is the type for deleting shortcuts.
|
||||||
|
ActivityShortcutDelete ActivityType = "shortcut.delete"
|
||||||
|
|
||||||
|
// Resource related.
|
||||||
|
|
||||||
|
// ActivityResourceCreate is the type for creating resources.
|
||||||
|
ActivityResourceCreate ActivityType = "resource.create"
|
||||||
|
// ActivityResourceDelete is the type for deleting resources.
|
||||||
|
ActivityResourceDelete ActivityType = "resource.delete"
|
||||||
|
|
||||||
|
// Tag related.
|
||||||
|
|
||||||
|
// ActivityTagCreate is the type for creating tags.
|
||||||
|
ActivityTagCreate ActivityType = "tag.create"
|
||||||
|
// ActivityTagDelete is the type for deleting tags.
|
||||||
|
ActivityTagDelete ActivityType = "tag.delete"
|
||||||
|
|
||||||
|
// Server related.
|
||||||
|
|
||||||
|
// ActivityServerStart is the type for starting server.
|
||||||
|
ActivityServerStart ActivityType = "server.start"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t ActivityType) String() string {
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityLevel is the level of activities.
|
||||||
|
type ActivityLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActivityInfo is the INFO level of activities.
|
||||||
|
ActivityInfo ActivityLevel = "INFO"
|
||||||
|
// ActivityWarn is the WARN level of activities.
|
||||||
|
ActivityWarn ActivityLevel = "WARN"
|
||||||
|
// ActivityError is the ERROR level of activities.
|
||||||
|
ActivityError ActivityLevel = "ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l ActivityLevel) String() string {
|
||||||
|
return string(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityUserCreatePayload struct {
|
||||||
|
UserID int `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityUserAuthSignInPayload struct {
|
||||||
|
UserID int `json:"userId"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityUserAuthSignUpPayload struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityMemoCreatePayload struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityShortcutCreatePayload struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityResourceCreatePayload struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityTagCreatePayload struct {
|
||||||
|
TagName string `json:"tagName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityServerStartPayload struct {
|
||||||
|
ServerID string `json:"serverId"`
|
||||||
|
Profile *profile.Profile `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Activity struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
CreatorID int `json:"creatorId"`
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Type ActivityType `json:"type"`
|
||||||
|
Level ActivityLevel `json:"level"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityCreate is the API message for creating an activity.
|
||||||
|
type ActivityCreate struct {
|
||||||
|
// Standard fields
|
||||||
|
CreatorID int
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Type ActivityType `json:"type"`
|
||||||
|
Level ActivityLevel
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/api/v1/auth"
|
||||||
|
"github.com/usememos/memos/common/util"
|
||||||
|
"github.com/usememos/memos/plugin/idp"
|
||||||
|
"github.com/usememos/memos/plugin/idp/oauth2"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignIn struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSOSignIn struct {
|
||||||
|
IdentityProviderID int `json:"identityProviderId"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
RedirectURI string `json:"redirectUri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignUp struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerAuthRoutes(g *echo.Group) {
|
||||||
|
// POST /auth/signin - Sign in.
|
||||||
|
g.POST("/auth/signin", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
signin := &SignIn{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
Username: &signin.Username,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||||
|
} else if user.RowStatus == store.Archived {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", signin.Username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
||||||
|
// If the two passwords don't match, return a 401 status.
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Incorrect login credentials, please try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.createAuthSignInActivity(c, user); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, user)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /auth/signin/sso - Sign in with SSO
|
||||||
|
g.POST("/auth/signin/sso", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
signin := &SSOSignIn{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
|
||||||
|
ID: &signin.IdentityProviderID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider").SetInternal(err)
|
||||||
|
}
|
||||||
|
if identityProvider == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo *idp.IdentityProviderUserInfo
|
||||||
|
if identityProvider.Type == store.IdentityProviderOAuth2Type {
|
||||||
|
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.OAuth2Config)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider instance").SetInternal(err)
|
||||||
|
}
|
||||||
|
token, err := oauth2IdentityProvider.ExchangeToken(ctx, signin.RedirectURI, signin.Code)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to exchange token").SetInternal(err)
|
||||||
|
}
|
||||||
|
userInfo, err = oauth2IdentityProvider.UserInfo(token)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user info").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifierFilter := identityProvider.IdentifierFilter
|
||||||
|
if identifierFilter != "" {
|
||||||
|
identifierFilterRegex, err := regexp.Compile(identifierFilter)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile identifier filter").SetInternal(err)
|
||||||
|
}
|
||||||
|
if !identifierFilterRegex.MatchString(userInfo.Identifier) {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Access denied, identifier does not match the filter.").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
Username: &userInfo.Identifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Incorrect login credentials, please try again")
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
userCreate := &store.User{
|
||||||
|
Username: userInfo.Identifier,
|
||||||
|
// The new signup user should be normal user by default.
|
||||||
|
Role: store.RoleUser,
|
||||||
|
Nickname: userInfo.DisplayName,
|
||||||
|
Email: userInfo.Email,
|
||||||
|
OpenID: util.GenUUID(),
|
||||||
|
}
|
||||||
|
password, err := util.RandomString(20)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate random password").SetInternal(err)
|
||||||
|
}
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||||
|
}
|
||||||
|
userCreate.PasswordHash = string(passwordHash)
|
||||||
|
user, err = s.Store.CreateUser(ctx, userCreate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.RowStatus == store.Archived {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.createAuthSignInActivity(c, user); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, user)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /auth/signup - Sign up a new user.
|
||||||
|
g.POST("/auth/signup", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
signup := &SignUp{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostUserType := store.RoleHost
|
||||||
|
existedHostUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||||
|
Role: &hostUserType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Failed to find users").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userCreate := &store.User{
|
||||||
|
Username: signup.Username,
|
||||||
|
// The new signup user should be normal user by default.
|
||||||
|
Role: store.RoleUser,
|
||||||
|
Nickname: signup.Username,
|
||||||
|
OpenID: util.GenUUID(),
|
||||||
|
}
|
||||||
|
if len(existedHostUsers) == 0 {
|
||||||
|
// Change the default role to host if there is no host user.
|
||||||
|
userCreate.Role = store.RoleHost
|
||||||
|
} else {
|
||||||
|
allowSignUpSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||||
|
Name: SystemSettingAllowSignUpName.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowSignUpSettingValue := false
|
||||||
|
if allowSignUpSetting != nil {
|
||||||
|
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowSignUpSettingValue {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "signup is disabled").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userCreate.PasswordHash = string(passwordHash)
|
||||||
|
user, err := s.Store.CreateUser(ctx, userCreate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := auth.GenerateTokensAndSetCookies(c, user, s.Secret); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.createAuthSignUpActivity(c, user); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, user)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /auth/signout - Sign out.
|
||||||
|
g.POST("/auth/signout", func(c echo.Context) error {
|
||||||
|
auth.RemoveTokensAndCookies(c)
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createAuthSignInActivity(c echo.Context, user *store.User) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
payload := ActivityUserAuthSignInPayload{
|
||||||
|
UserID: user.ID,
|
||||||
|
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||||
|
CreatorID: user.ID,
|
||||||
|
Type: string(ActivityUserAuthSignIn),
|
||||||
|
Level: string(ActivityInfo),
|
||||||
|
Payload: string(payloadBytes),
|
||||||
|
})
|
||||||
|
if err != nil || activity == nil {
|
||||||
|
return errors.Wrap(err, "failed to create activity")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createAuthSignUpActivity(c echo.Context, user *store.User) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
payload := ActivityUserAuthSignUpPayload{
|
||||||
|
Username: user.Username,
|
||||||
|
IP: echo.ExtractIPFromRealIPHeader()(c.Request()),
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||||
|
CreatorID: user.ID,
|
||||||
|
Type: string(ActivityUserAuthSignUp),
|
||||||
|
Level: string(ActivityInfo),
|
||||||
|
Payload: string(payloadBytes),
|
||||||
|
})
|
||||||
|
if err != nil || activity == nil {
|
||||||
|
return errors.Wrap(err, "failed to create activity")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
issuer = "memos"
|
||||||
|
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
||||||
|
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
||||||
|
keyID = "v1"
|
||||||
|
// AccessTokenAudienceName is the audience name of the access token.
|
||||||
|
AccessTokenAudienceName = "user.access-token"
|
||||||
|
// RefreshTokenAudienceName is the audience name of the refresh token.
|
||||||
|
RefreshTokenAudienceName = "user.refresh-token"
|
||||||
|
apiTokenDuration = 2 * time.Hour
|
||||||
|
accessTokenDuration = 24 * time.Hour
|
||||||
|
refreshTokenDuration = 7 * 24 * time.Hour
|
||||||
|
// RefreshThresholdDuration is the threshold duration for refreshing token.
|
||||||
|
RefreshThresholdDuration = 1 * time.Hour
|
||||||
|
|
||||||
|
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
||||||
|
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||||
|
// Suppose we have a valid refresh token, we will refresh the token in 2 cases:
|
||||||
|
// 1. The access token is about to expire in <<refreshThresholdDuration>>
|
||||||
|
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
||||||
|
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
||||||
|
// AccessTokenCookieName is the cookie name of access token.
|
||||||
|
AccessTokenCookieName = "memos.access-token"
|
||||||
|
// RefreshTokenCookieName is the cookie name of refresh token.
|
||||||
|
RefreshTokenCookieName = "memos.refresh-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type claimsMessage struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAPIToken generates an API token.
|
||||||
|
func GenerateAPIToken(userName string, userID int, secret string) (string, error) {
|
||||||
|
expirationTime := time.Now().Add(apiTokenDuration)
|
||||||
|
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAccessToken generates an access token for web.
|
||||||
|
func GenerateAccessToken(userName string, userID int, secret string) (string, error) {
|
||||||
|
expirationTime := time.Now().Add(accessTokenDuration)
|
||||||
|
return generateToken(userName, userID, AccessTokenAudienceName, expirationTime, []byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRefreshToken generates a refresh token for web.
|
||||||
|
func GenerateRefreshToken(userName string, userID int, secret string) (string, error) {
|
||||||
|
expirationTime := time.Now().Add(refreshTokenDuration)
|
||||||
|
return generateToken(userName, userID, RefreshTokenAudienceName, expirationTime, []byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTokensAndSetCookies generates jwt token and saves it to the http-only cookie.
|
||||||
|
func GenerateTokensAndSetCookies(c echo.Context, user *store.User, secret string) error {
|
||||||
|
accessToken, err := GenerateAccessToken(user.Username, user.ID, secret)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to generate access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieExp := time.Now().Add(CookieExpDuration)
|
||||||
|
setTokenCookie(c, AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
|
||||||
|
// We generate here a new refresh token and saving it to the cookie.
|
||||||
|
refreshToken, err := GenerateRefreshToken(user.Username, user.ID, secret)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to generate refresh token")
|
||||||
|
}
|
||||||
|
setTokenCookie(c, RefreshTokenCookieName, refreshToken, cookieExp)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTokensAndCookies removes the jwt token and refresh token from the cookies.
|
||||||
|
func RemoveTokensAndCookies(c echo.Context) {
|
||||||
|
// We set the expiration time to the past, so that the cookie will be removed.
|
||||||
|
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||||
|
setTokenCookie(c, AccessTokenCookieName, "", cookieExp)
|
||||||
|
setTokenCookie(c, RefreshTokenCookieName, "", cookieExp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTokenCookie sets the token to the cookie.
|
||||||
|
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||||
|
cookie := new(http.Cookie)
|
||||||
|
cookie.Name = name
|
||||||
|
cookie.Value = token
|
||||||
|
cookie.Expires = expiration
|
||||||
|
cookie.Path = "/"
|
||||||
|
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||||
|
cookie.HttpOnly = true
|
||||||
|
cookie.SameSite = http.SameSiteStrictMode
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken generates a jwt token.
|
||||||
|
func generateToken(username string, userID int, aud string, expirationTime time.Time, secret []byte) (string, error) {
|
||||||
|
// Create the JWT claims, which includes the username and expiry time.
|
||||||
|
claims := &claimsMessage{
|
||||||
|
Name: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Audience: jwt.ClaimStrings{aud},
|
||||||
|
// In JWT, the expiry time is expressed as unix milliseconds.
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: issuer,
|
||||||
|
Subject: strconv.Itoa(userID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare the token with the HS256 algorithm used for signing, and the claims.
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
token.Header["kid"] = keyID
|
||||||
|
|
||||||
|
// Create the JWT string.
|
||||||
|
tokenString, err := token.SignedString(secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
// UnknownID is the ID for unknowns.
|
||||||
|
const UnknownID = -1
|
||||||
|
|
||||||
|
// RowStatus is the status for a row.
|
||||||
|
type RowStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Normal is the status for a normal row.
|
||||||
|
Normal RowStatus = "NORMAL"
|
||||||
|
// Archived is the status for an archived row.
|
||||||
|
Archived RowStatus = "ARCHIVED"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r RowStatus) String() string {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
getter "github.com/usememos/memos/plugin/http-getter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (*APIV1Service) registerGetterPublicRoutes(g *echo.Group) {
|
||||||
|
// GET /get/httpmeta?url={url} - Get website meta.
|
||||||
|
g.GET("/get/httpmeta", func(c echo.Context) error {
|
||||||
|
urlStr := c.QueryParam("url")
|
||||||
|
if urlStr == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing website url")
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(urlStr); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlMeta, err := getter.GetHTMLMeta(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotAcceptable, fmt.Sprintf("Failed to get website meta with url: %s", urlStr)).SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, htmlMeta)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /get/image?url={url} - Get image.
|
||||||
|
g.GET("/get/image", func(c echo.Context) error {
|
||||||
|
urlStr := c.QueryParam("url")
|
||||||
|
if urlStr == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing image url")
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(urlStr); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Wrong url").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := getter.GetImage(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to get image url: %s", urlStr)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Writer.WriteHeader(http.StatusOK)
|
||||||
|
c.Response().Writer.Header().Set("Content-Type", image.Mediatype)
|
||||||
|
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
|
||||||
|
if _, err := c.Response().Writer.Write(image.Blob); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to write image blob").SetInternal(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IdentityProviderType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IdentityProviderOAuth2Type IdentityProviderType = "OAUTH2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t IdentityProviderType) String() string {
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentityProviderConfig struct {
|
||||||
|
OAuth2Config *IdentityProviderOAuth2Config `json:"oauth2Config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentityProviderOAuth2Config struct {
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
ClientSecret string `json:"clientSecret"`
|
||||||
|
AuthURL string `json:"authUrl"`
|
||||||
|
TokenURL string `json:"tokenUrl"`
|
||||||
|
UserInfoURL string `json:"userInfoUrl"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
FieldMapping *FieldMapping `json:"fieldMapping"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldMapping struct {
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentityProvider struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type IdentityProviderType `json:"type"`
|
||||||
|
IdentifierFilter string `json:"identifierFilter"`
|
||||||
|
Config *IdentityProviderConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateIdentityProviderRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type IdentityProviderType `json:"type"`
|
||||||
|
IdentifierFilter string `json:"identifierFilter"`
|
||||||
|
Config *IdentityProviderConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateIdentityProviderRequest struct {
|
||||||
|
ID int `json:"-"`
|
||||||
|
Type IdentityProviderType `json:"type"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
IdentifierFilter *string `json:"identifierFilter"`
|
||||||
|
Config *IdentityProviderConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerIdentityProviderRoutes(g *echo.Group) {
|
||||||
|
g.POST("/idp", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderCreate := &CreateIdentityProviderRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(identityProviderCreate); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post identity provider request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProvider, err := s.Store.CreateIdentityProvider(ctx, &store.IdentityProvider{
|
||||||
|
Name: identityProviderCreate.Name,
|
||||||
|
Type: store.IdentityProviderType(identityProviderCreate.Type),
|
||||||
|
IdentifierFilter: identityProviderCreate.IdentifierFilter,
|
||||||
|
Config: convertIdentityProviderConfigToStore(identityProviderCreate.Config),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.PATCH("/idp/:idpId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderID, err := strconv.Atoi(c.Param("idpId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderPatch := &UpdateIdentityProviderRequest{
|
||||||
|
ID: identityProviderID,
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(identityProviderPatch); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch identity provider request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProvider, err := s.Store.UpdateIdentityProvider(ctx, &store.UpdateIdentityProvider{
|
||||||
|
ID: identityProviderPatch.ID,
|
||||||
|
Type: store.IdentityProviderType(identityProviderPatch.Type),
|
||||||
|
Name: identityProviderPatch.Name,
|
||||||
|
IdentifierFilter: identityProviderPatch.IdentifierFilter,
|
||||||
|
Config: convertIdentityProviderConfigToStore(identityProviderPatch.Config),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch identity provider").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/idp", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
list, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProvider{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
isHostUser := false
|
||||||
|
if ok {
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role == store.RoleHost {
|
||||||
|
isHostUser = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderList := []*IdentityProvider{}
|
||||||
|
for _, item := range list {
|
||||||
|
identityProvider := convertIdentityProviderFromStore(item)
|
||||||
|
// data desensitize
|
||||||
|
if !isHostUser {
|
||||||
|
identityProvider.Config.OAuth2Config.ClientSecret = ""
|
||||||
|
}
|
||||||
|
identityProviderList = append(identityProviderList, identityProvider)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, identityProviderList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/idp/:idpId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderID, err := strconv.Atoi(c.Param("idpId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
|
||||||
|
ID: &identityProviderID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get identity provider").SetInternal(err)
|
||||||
|
}
|
||||||
|
if identityProvider == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "Identity provider not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, convertIdentityProviderFromStore(identityProvider))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/idp/:idpId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderID, err := strconv.Atoi(c.Param("idpId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("idpId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.Store.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: identityProviderID}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete identity provider").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIdentityProviderFromStore(identityProvider *store.IdentityProvider) *IdentityProvider {
|
||||||
|
return &IdentityProvider{
|
||||||
|
ID: identityProvider.ID,
|
||||||
|
Name: identityProvider.Name,
|
||||||
|
Type: IdentityProviderType(identityProvider.Type),
|
||||||
|
IdentifierFilter: identityProvider.IdentifierFilter,
|
||||||
|
Config: convertIdentityProviderConfigFromStore(identityProvider.Config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIdentityProviderConfigFromStore(config *store.IdentityProviderConfig) *IdentityProviderConfig {
|
||||||
|
return &IdentityProviderConfig{
|
||||||
|
OAuth2Config: &IdentityProviderOAuth2Config{
|
||||||
|
ClientID: config.OAuth2Config.ClientID,
|
||||||
|
ClientSecret: config.OAuth2Config.ClientSecret,
|
||||||
|
AuthURL: config.OAuth2Config.AuthURL,
|
||||||
|
TokenURL: config.OAuth2Config.TokenURL,
|
||||||
|
UserInfoURL: config.OAuth2Config.UserInfoURL,
|
||||||
|
Scopes: config.OAuth2Config.Scopes,
|
||||||
|
FieldMapping: &FieldMapping{
|
||||||
|
Identifier: config.OAuth2Config.FieldMapping.Identifier,
|
||||||
|
DisplayName: config.OAuth2Config.FieldMapping.DisplayName,
|
||||||
|
Email: config.OAuth2Config.FieldMapping.Email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIdentityProviderConfigToStore(config *IdentityProviderConfig) *store.IdentityProviderConfig {
|
||||||
|
return &store.IdentityProviderConfig{
|
||||||
|
OAuth2Config: &store.IdentityProviderOAuth2Config{
|
||||||
|
ClientID: config.OAuth2Config.ClientID,
|
||||||
|
ClientSecret: config.OAuth2Config.ClientSecret,
|
||||||
|
AuthURL: config.OAuth2Config.AuthURL,
|
||||||
|
TokenURL: config.OAuth2Config.TokenURL,
|
||||||
|
UserInfoURL: config.OAuth2Config.UserInfoURL,
|
||||||
|
Scopes: config.OAuth2Config.Scopes,
|
||||||
|
FieldMapping: &store.FieldMapping{
|
||||||
|
Identifier: config.OAuth2Config.FieldMapping.Identifier,
|
||||||
|
DisplayName: config.OAuth2Config.FieldMapping.DisplayName,
|
||||||
|
Email: config.OAuth2Config.FieldMapping.Email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/api/v1/auth"
|
||||||
|
"github.com/usememos/memos/common/util"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Context section
|
||||||
|
// The key name used to store user id in the context
|
||||||
|
// user id is extracted from the jwt token subject field.
|
||||||
|
userIDContextKey = "user-id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getUserIDContextKey() string {
|
||||||
|
return userIDContextKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims creates a struct that will be encoded to a JWT.
|
||||||
|
// We add jwt.RegisteredClaims as an embedded type, to provide fields such as name.
|
||||||
|
type Claims struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeaderParts := strings.Fields(authHeader)
|
||||||
|
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||||
|
return "", errors.New("Authorization header format must be Bearer {token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return authHeaderParts[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAccessToken(c echo.Context) string {
|
||||||
|
accessToken := ""
|
||||||
|
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||||
|
if cookie != nil {
|
||||||
|
accessToken = cookie.Value
|
||||||
|
}
|
||||||
|
if accessToken == "" {
|
||||||
|
accessToken, _ = extractTokenFromHeader(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func audienceContains(audience jwt.ClaimStrings, token string) bool {
|
||||||
|
for _, v := range audience {
|
||||||
|
if v == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTMiddleware validates the access token.
|
||||||
|
// If the access token is about to expire or has expired and the request has a valid refresh token, it
|
||||||
|
// will try to generate new access token and refresh token.
|
||||||
|
func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
path := c.Request().URL.Path
|
||||||
|
method := c.Request().Method
|
||||||
|
|
||||||
|
if server.defaultAuthSkipper(c) {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip validation for server status endpoints.
|
||||||
|
if util.HasPrefixes(path, "/api/v1/ping", "/api/v1/idp", "/api/v1/status", "/api/v1/user/:id") && method == http.MethodGet {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := findAccessToken(c)
|
||||||
|
if token == "" {
|
||||||
|
// Allow the user to access the public endpoints.
|
||||||
|
if util.HasPrefixes(path, "/o") {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
// When the request is not authenticated, we allow the user to access the memo endpoints for those public memos.
|
||||||
|
if util.HasPrefixes(path, "/api/v1/memo") && method == http.MethodGet {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &Claims{}
|
||||||
|
accessToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
|
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
|
}
|
||||||
|
if kid, ok := t.Header["kid"].(string); ok {
|
||||||
|
if kid == "v1" {
|
||||||
|
return []byte(secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||||
|
})
|
||||||
|
|
||||||
|
if !accessToken.Valid {
|
||||||
|
auth.RemoveTokensAndCookies(c)
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
||||||
|
}
|
||||||
|
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
||||||
|
}
|
||||||
|
|
||||||
|
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
||||||
|
if err != nil {
|
||||||
|
var ve *jwt.ValidationError
|
||||||
|
if errors.As(err, &ve) {
|
||||||
|
// If expiration error is the only error, we will clear the err
|
||||||
|
// and generate new access token and refresh token
|
||||||
|
if ve.Errors == jwt.ValidationErrorExpired {
|
||||||
|
generateToken = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
auth.RemoveTokensAndCookies(c)
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, err := strconv.Atoi(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even if there is no error, we still need to make sure the user still exists.
|
||||||
|
user, err := server.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if generateToken {
|
||||||
|
generateTokenFunc := func() error {
|
||||||
|
rc, err := c.Cookie(auth.RefreshTokenCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Missing refresh token.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses token and checks if it's valid.
|
||||||
|
refreshTokenClaims := &Claims{}
|
||||||
|
refreshToken, err := jwt.ParseWithClaims(rc.Value, refreshTokenClaims, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||||
|
return nil, errors.Errorf("unexpected refresh token signing method=%v, expected %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||||
|
}
|
||||||
|
|
||||||
|
if kid, ok := t.Header["kid"].(string); ok {
|
||||||
|
if kid == "v1" {
|
||||||
|
return []byte(secret), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.Errorf("unexpected refresh token kid=%v", t.Header["kid"])
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if err == jwt.ErrSignatureInvalid {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to generate access token. Invalid refresh token signature.")
|
||||||
|
}
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !audienceContains(refreshTokenClaims.Audience, auth.RefreshTokenAudienceName) {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||||
|
fmt.Sprintf("Invalid refresh token, audience mismatch, got %q, expected %q. you may send request to the wrong environment",
|
||||||
|
refreshTokenClaims.Audience,
|
||||||
|
auth.RefreshTokenAudienceName,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a valid refresh token, we will generate new access token and refresh token
|
||||||
|
if refreshToken != nil && refreshToken.Valid {
|
||||||
|
if err := auth.GenerateTokensAndSetCookies(c, user, secret); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to refresh expired token. User Id %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// It may happen that we still have a valid access token, but we encounter issue when trying to generate new token
|
||||||
|
// In such case, we won't return the error.
|
||||||
|
if err := generateTokenFunc(); err != nil && !accessToken.Valid {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores userID into context.
|
||||||
|
c.Set(getUserIDContextKey(), userID)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) defaultAuthSkipper(c echo.Context) bool {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
path := c.Path()
|
||||||
|
|
||||||
|
// Skip auth.
|
||||||
|
if util.HasPrefixes(path, "/api/v1/auth") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is openId in query string and related user is found, then skip auth.
|
||||||
|
openID := c.QueryParam("openId")
|
||||||
|
if openID != "" {
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
OpenID: &openID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if user != nil {
|
||||||
|
// Stores userID into context.
|
||||||
|
c.Set(getUserIDContextKey(), user.ID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,761 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Visibility is the type of a visibility.
|
||||||
|
type Visibility string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Public is the PUBLIC visibility.
|
||||||
|
Public Visibility = "PUBLIC"
|
||||||
|
// Protected is the PROTECTED visibility.
|
||||||
|
Protected Visibility = "PROTECTED"
|
||||||
|
// Private is the PRIVATE visibility.
|
||||||
|
Private Visibility = "PRIVATE"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v Visibility) String() string {
|
||||||
|
switch v {
|
||||||
|
case Public:
|
||||||
|
return "PUBLIC"
|
||||||
|
case Protected:
|
||||||
|
return "PROTECTED"
|
||||||
|
case Private:
|
||||||
|
return "PRIVATE"
|
||||||
|
}
|
||||||
|
return "PRIVATE"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Memo struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
RowStatus RowStatus `json:"rowStatus"`
|
||||||
|
CreatorID int `json:"creatorId"`
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
DisplayTs int64 `json:"displayTs"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Visibility Visibility `json:"visibility"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
|
||||||
|
// Related fields
|
||||||
|
CreatorName string `json:"creatorName"`
|
||||||
|
ResourceList []*Resource `json:"resourceList"`
|
||||||
|
RelationList []*MemoRelation `json:"relationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateMemoRequest struct {
|
||||||
|
// Standard fields
|
||||||
|
CreatorID int `json:"-"`
|
||||||
|
CreatedTs *int64 `json:"createdTs"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Visibility Visibility `json:"visibility"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
|
||||||
|
// Related fields
|
||||||
|
ResourceIDList []int `json:"resourceIdList"`
|
||||||
|
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatchMemoRequest struct {
|
||||||
|
ID int `json:"-"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
CreatedTs *int64 `json:"createdTs"`
|
||||||
|
UpdatedTs *int64
|
||||||
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Visibility *Visibility `json:"visibility"`
|
||||||
|
|
||||||
|
// Related fields
|
||||||
|
ResourceIDList []int `json:"resourceIdList"`
|
||||||
|
RelationList []*UpsertMemoRelationRequest `json:"relationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindMemoRequest struct {
|
||||||
|
ID *int
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
RowStatus *RowStatus
|
||||||
|
CreatorID *int
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Pinned *bool
|
||||||
|
ContentSearch []string
|
||||||
|
VisibilityList []Visibility
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
Limit *int
|
||||||
|
Offset *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxContentLength means the max memo content bytes is 1MB.
|
||||||
|
const maxContentLength = 1 << 30
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerMemoRoutes(g *echo.Group) {
|
||||||
|
g.POST("/memo", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
createMemoRequest := &CreateMemoRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(createMemoRequest); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(createMemoRequest.Content) > maxContentLength {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB")
|
||||||
|
}
|
||||||
|
|
||||||
|
if createMemoRequest.Visibility == "" {
|
||||||
|
userMemoVisibilitySetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
|
||||||
|
UserID: &userID,
|
||||||
|
Key: UserSettingMemoVisibilityKey.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
|
||||||
|
}
|
||||||
|
if userMemoVisibilitySetting != nil {
|
||||||
|
memoVisibility := Private
|
||||||
|
err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
|
||||||
|
}
|
||||||
|
createMemoRequest.Visibility = memoVisibility
|
||||||
|
} else {
|
||||||
|
// Private is the default memo visibility.
|
||||||
|
createMemoRequest.Visibility = Private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find disable public memos system setting.
|
||||||
|
disablePublicMemosSystemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||||
|
Name: SystemSettingDisablePublicMemosName.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
|
||||||
|
}
|
||||||
|
if disablePublicMemosSystemSetting != nil {
|
||||||
|
disablePublicMemos := false
|
||||||
|
err = json.Unmarshal([]byte(disablePublicMemosSystemSetting.Value), &disablePublicMemos)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
|
||||||
|
}
|
||||||
|
if disablePublicMemos {
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||||
|
}
|
||||||
|
// Enforce normal user to create private memo if public memos are disabled.
|
||||||
|
if user.Role == store.RoleUser {
|
||||||
|
createMemoRequest.Visibility = Private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createMemoRequest.CreatorID = userID
|
||||||
|
memo, err := s.Store.CreateMemo(ctx, convertCreateMemoRequestToMemoMessage(createMemoRequest))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.createMemoCreateActivity(ctx, memo); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, resourceID := range createMemoRequest.ResourceIDList {
|
||||||
|
if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{
|
||||||
|
MemoID: memo.ID,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, memoRelationUpsert := range createMemoRequest.RelationList {
|
||||||
|
if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||||||
|
MemoID: memo.ID,
|
||||||
|
RelatedMemoID: memoRelationUpsert.RelatedMemoID,
|
||||||
|
Type: store.MemoRelationType(memoRelationUpsert.Type),
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &memo.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memo.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.PATCH("/memo/:memoId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
|
||||||
|
}
|
||||||
|
if memo.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTs := time.Now().Unix()
|
||||||
|
patchMemoRequest := &PatchMemoRequest{
|
||||||
|
ID: memoID,
|
||||||
|
UpdatedTs: ¤tTs,
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(patchMemoRequest); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if patchMemoRequest.Content != nil && len(*patchMemoRequest.Content) > maxContentLength {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Content size overflow, up to 1MB").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMemoMessage := &store.UpdateMemo{
|
||||||
|
ID: memoID,
|
||||||
|
CreatedTs: patchMemoRequest.CreatedTs,
|
||||||
|
UpdatedTs: patchMemoRequest.UpdatedTs,
|
||||||
|
Content: patchMemoRequest.Content,
|
||||||
|
}
|
||||||
|
if patchMemoRequest.RowStatus != nil {
|
||||||
|
rowStatus := store.RowStatus(patchMemoRequest.RowStatus.String())
|
||||||
|
updateMemoMessage.RowStatus = &rowStatus
|
||||||
|
}
|
||||||
|
if patchMemoRequest.Visibility != nil {
|
||||||
|
visibility := store.Visibility(patchMemoRequest.Visibility.String())
|
||||||
|
updateMemoMessage.Visibility = &visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Store.UpdateMemo(ctx, updateMemoMessage)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if patchMemoRequest.ResourceIDList != nil {
|
||||||
|
addedResourceIDList, removedResourceIDList := getIDListDiff(memo.ResourceIDList, patchMemoRequest.ResourceIDList)
|
||||||
|
for _, resourceID := range addedResourceIDList {
|
||||||
|
if _, err := s.Store.UpsertMemoResource(ctx, &store.UpsertMemoResource{
|
||||||
|
MemoID: memo.ID,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, resourceID := range removedResourceIDList {
|
||||||
|
if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{
|
||||||
|
MemoID: &memo.ID,
|
||||||
|
ResourceID: &resourceID,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patchMemoRequest.RelationList != nil {
|
||||||
|
patchMemoRelationList := make([]*store.MemoRelation, 0)
|
||||||
|
for _, memoRelation := range patchMemoRequest.RelationList {
|
||||||
|
patchMemoRelationList = append(patchMemoRelationList, &store.MemoRelation{
|
||||||
|
MemoID: memo.ID,
|
||||||
|
RelatedMemoID: memoRelation.RelatedMemoID,
|
||||||
|
Type: store.MemoRelationType(memoRelation.Type),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.RelationList, patchMemoRelationList)
|
||||||
|
for _, memoRelation := range addedMemoRelationList {
|
||||||
|
if _, err := s.Store.UpsertMemoRelation(ctx, memoRelation); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, memoRelation := range removedMemoRelationList {
|
||||||
|
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
|
||||||
|
MemoID: &memo.ID,
|
||||||
|
RelatedMemoID: &memoRelation.RelatedMemoID,
|
||||||
|
Type: &memoRelation.Type,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ID: &memoID})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
|
||||||
|
}
|
||||||
|
|
||||||
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/memo", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
findMemoMessage := &store.FindMemo{}
|
||||||
|
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||||
|
findMemoMessage.CreatorID = &userID
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
// Anonymous use should only fetch PUBLIC memos with specified user
|
||||||
|
if findMemoMessage.CreatorID == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
|
||||||
|
}
|
||||||
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
|
||||||
|
} else {
|
||||||
|
// Authorized user can fetch all PUBLIC/PROTECTED memo
|
||||||
|
visibilityList := []store.Visibility{store.Public, store.Protected}
|
||||||
|
|
||||||
|
// If Creator is authorized user (as default), PRIVATE memo is OK
|
||||||
|
if findMemoMessage.CreatorID == nil || *findMemoMessage.CreatorID == currentUserID {
|
||||||
|
findMemoMessage.CreatorID = ¤tUserID
|
||||||
|
visibilityList = append(visibilityList, store.Private)
|
||||||
|
}
|
||||||
|
findMemoMessage.VisibilityList = visibilityList
|
||||||
|
}
|
||||||
|
|
||||||
|
rowStatus := store.RowStatus(c.QueryParam("rowStatus"))
|
||||||
|
if rowStatus != "" {
|
||||||
|
findMemoMessage.RowStatus = &rowStatus
|
||||||
|
}
|
||||||
|
pinnedStr := c.QueryParam("pinned")
|
||||||
|
if pinnedStr != "" {
|
||||||
|
pinned := pinnedStr == "true"
|
||||||
|
findMemoMessage.Pinned = &pinned
|
||||||
|
}
|
||||||
|
|
||||||
|
contentSearch := []string{}
|
||||||
|
tag := c.QueryParam("tag")
|
||||||
|
if tag != "" {
|
||||||
|
contentSearch = append(contentSearch, "#"+tag)
|
||||||
|
}
|
||||||
|
contentSlice := c.QueryParams()["content"]
|
||||||
|
if len(contentSlice) > 0 {
|
||||||
|
contentSearch = append(contentSearch, contentSlice...)
|
||||||
|
}
|
||||||
|
findMemoMessage.ContentSearch = contentSearch
|
||||||
|
|
||||||
|
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||||
|
findMemoMessage.Limit = &limit
|
||||||
|
}
|
||||||
|
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||||
|
findMemoMessage.Offset = &offset
|
||||||
|
}
|
||||||
|
|
||||||
|
memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memoDisplayWithUpdatedTs {
|
||||||
|
findMemoMessage.OrderByUpdatedTs = true
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListMemos(ctx, findMemoMessage)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
|
||||||
|
}
|
||||||
|
memoResponseList := []*Memo{}
|
||||||
|
for _, memo := range list {
|
||||||
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||||
|
}
|
||||||
|
memoResponseList = append(memoResponseList, memoResponse)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoResponseList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/memo/:memoId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if memo.Visibility == store.Private {
|
||||||
|
if !ok || memo.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "this memo is private only")
|
||||||
|
}
|
||||||
|
} else if memo.Visibility == store.Protected {
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "this memo is protected, missing user in session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/memo/stats", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
normalStatus := store.Normal
|
||||||
|
findMemoMessage := &store.FindMemo{
|
||||||
|
RowStatus: &normalStatus,
|
||||||
|
}
|
||||||
|
if creatorID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||||
|
findMemoMessage.CreatorID = &creatorID
|
||||||
|
}
|
||||||
|
if findMemoMessage.CreatorID == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
|
||||||
|
} else {
|
||||||
|
if *findMemoMessage.CreatorID != currentUserID {
|
||||||
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
||||||
|
} else {
|
||||||
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected, store.Private}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memoDisplayWithUpdatedTs {
|
||||||
|
findMemoMessage.OrderByUpdatedTs = true
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListMemos(ctx, findMemoMessage)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||||
|
}
|
||||||
|
memoResponseList := []*Memo{}
|
||||||
|
for _, memo := range list {
|
||||||
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||||
|
}
|
||||||
|
memoResponseList = append(memoResponseList, memoResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTsList := []int64{}
|
||||||
|
for _, memo := range memoResponseList {
|
||||||
|
displayTsList = append(displayTsList, memo.DisplayTs)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, displayTsList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/memo/all", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
findMemoMessage := &store.FindMemo{}
|
||||||
|
_, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public}
|
||||||
|
} else {
|
||||||
|
findMemoMessage.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
||||||
|
}
|
||||||
|
|
||||||
|
pinnedStr := c.QueryParam("pinned")
|
||||||
|
if pinnedStr != "" {
|
||||||
|
pinned := pinnedStr == "true"
|
||||||
|
findMemoMessage.Pinned = &pinned
|
||||||
|
}
|
||||||
|
|
||||||
|
contentSearch := []string{}
|
||||||
|
tag := c.QueryParam("tag")
|
||||||
|
if tag != "" {
|
||||||
|
contentSearch = append(contentSearch, "#"+tag+" ")
|
||||||
|
}
|
||||||
|
contentSlice := c.QueryParams()["content"]
|
||||||
|
if len(contentSlice) > 0 {
|
||||||
|
contentSearch = append(contentSearch, contentSlice...)
|
||||||
|
}
|
||||||
|
findMemoMessage.ContentSearch = contentSearch
|
||||||
|
|
||||||
|
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||||
|
findMemoMessage.Limit = &limit
|
||||||
|
}
|
||||||
|
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||||
|
findMemoMessage.Offset = &offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fetch normal status memos.
|
||||||
|
normalStatus := store.Normal
|
||||||
|
findMemoMessage.RowStatus = &normalStatus
|
||||||
|
|
||||||
|
memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get memo display with updated ts setting value").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memoDisplayWithUpdatedTs {
|
||||||
|
findMemoMessage.OrderByUpdatedTs = true
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListMemos(ctx, findMemoMessage)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch all memo list").SetInternal(err)
|
||||||
|
}
|
||||||
|
memoResponseList := []*Memo{}
|
||||||
|
for _, memo := range list {
|
||||||
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||||
|
}
|
||||||
|
memoResponseList = append(memoResponseList, memoResponse)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoResponseList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/memo/:memoId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %d", memoID))
|
||||||
|
}
|
||||||
|
if memo.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{
|
||||||
|
ID: memoID,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createMemoCreateActivity(ctx context.Context, memo *store.Memo) error {
|
||||||
|
payload := ActivityMemoCreatePayload{
|
||||||
|
Content: memo.Content,
|
||||||
|
Visibility: memo.Visibility.String(),
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||||
|
CreatorID: memo.CreatorID,
|
||||||
|
Type: ActivityMemoCreate.String(),
|
||||||
|
Level: ActivityInfo.String(),
|
||||||
|
Payload: string(payloadBytes),
|
||||||
|
})
|
||||||
|
if err != nil || activity == nil {
|
||||||
|
return errors.Wrap(err, "failed to create activity")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo) (*Memo, error) {
|
||||||
|
memoResponse := &Memo{
|
||||||
|
ID: memo.ID,
|
||||||
|
RowStatus: RowStatus(memo.RowStatus.String()),
|
||||||
|
CreatorID: memo.CreatorID,
|
||||||
|
CreatedTs: memo.CreatedTs,
|
||||||
|
UpdatedTs: memo.UpdatedTs,
|
||||||
|
Content: memo.Content,
|
||||||
|
Visibility: Visibility(memo.Visibility.String()),
|
||||||
|
Pinned: memo.Pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose creator name.
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &memoResponse.CreatorID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.Nickname != "" {
|
||||||
|
memoResponse.CreatorName = user.Nickname
|
||||||
|
} else {
|
||||||
|
memoResponse.CreatorName = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose display ts.
|
||||||
|
memoResponse.DisplayTs = memoResponse.CreatedTs
|
||||||
|
// Find memo display with updated ts setting.
|
||||||
|
memoDisplayWithUpdatedTs, err := s.getMemoDisplayWithUpdatedTsSettingValue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if memoDisplayWithUpdatedTs {
|
||||||
|
memoResponse.DisplayTs = memoResponse.UpdatedTs
|
||||||
|
}
|
||||||
|
|
||||||
|
relationList := []*MemoRelation{}
|
||||||
|
for _, relation := range memo.RelationList {
|
||||||
|
relationList = append(relationList, convertMemoRelationFromStore(relation))
|
||||||
|
}
|
||||||
|
memoResponse.RelationList = relationList
|
||||||
|
|
||||||
|
resourceList := []*Resource{}
|
||||||
|
for _, resourceID := range memo.ResourceIDList {
|
||||||
|
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||||
|
ID: &resourceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resource != nil {
|
||||||
|
resourceList = append(resourceList, convertResourceFromStore(resource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memoResponse.ResourceList = resourceList
|
||||||
|
|
||||||
|
return memoResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) getMemoDisplayWithUpdatedTsSettingValue(ctx context.Context) (bool, error) {
|
||||||
|
memoDisplayWithUpdatedTsSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||||
|
Name: SystemSettingMemoDisplayWithUpdatedTsName.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "failed to find system setting")
|
||||||
|
}
|
||||||
|
memoDisplayWithUpdatedTs := false
|
||||||
|
if memoDisplayWithUpdatedTsSetting != nil {
|
||||||
|
err = json.Unmarshal([]byte(memoDisplayWithUpdatedTsSetting.Value), &memoDisplayWithUpdatedTs)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "failed to unmarshal system setting value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return memoDisplayWithUpdatedTs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertCreateMemoRequestToMemoMessage(memoCreate *CreateMemoRequest) *store.Memo {
|
||||||
|
createdTs := time.Now().Unix()
|
||||||
|
if memoCreate.CreatedTs != nil {
|
||||||
|
createdTs = *memoCreate.CreatedTs
|
||||||
|
}
|
||||||
|
return &store.Memo{
|
||||||
|
CreatorID: memoCreate.CreatorID,
|
||||||
|
CreatedTs: createdTs,
|
||||||
|
Content: memoCreate.Content,
|
||||||
|
Visibility: store.Visibility(memoCreate.Visibility),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMemoRelationListDiff(oldList, newList []*store.MemoRelation) (addedList, removedList []*store.MemoRelation) {
|
||||||
|
oldMap := map[string]bool{}
|
||||||
|
for _, relation := range oldList {
|
||||||
|
oldMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
|
||||||
|
}
|
||||||
|
newMap := map[string]bool{}
|
||||||
|
for _, relation := range newList {
|
||||||
|
newMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
|
||||||
|
}
|
||||||
|
for _, relation := range oldList {
|
||||||
|
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
|
||||||
|
if !newMap[key] {
|
||||||
|
removedList = append(removedList, relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, relation := range newList {
|
||||||
|
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
|
||||||
|
if !oldMap[key] {
|
||||||
|
addedList = append(addedList, relation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addedList, removedList
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIDListDiff(oldList, newList []int) (addedList, removedList []int) {
|
||||||
|
oldMap := map[int]bool{}
|
||||||
|
for _, id := range oldList {
|
||||||
|
oldMap[id] = true
|
||||||
|
}
|
||||||
|
newMap := map[int]bool{}
|
||||||
|
for _, id := range newList {
|
||||||
|
newMap[id] = true
|
||||||
|
}
|
||||||
|
for id := range oldMap {
|
||||||
|
if !newMap[id] {
|
||||||
|
removedList = append(removedList, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range newMap {
|
||||||
|
if !oldMap[id] {
|
||||||
|
addedList = append(addedList, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addedList, removedList
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoOrganizer struct {
|
||||||
|
MemoID int `json:"memoId"`
|
||||||
|
UserID int `json:"userId"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertMemoOrganizerRequest struct {
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerMemoOrganizerRoutes(g *echo.Group) {
|
||||||
|
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
|
||||||
|
}
|
||||||
|
if memo.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &UpsertMemoOrganizerRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert := &store.MemoOrganizer{
|
||||||
|
MemoID: memoID,
|
||||||
|
UserID: userID,
|
||||||
|
Pinned: request.Pinned,
|
||||||
|
}
|
||||||
|
_, err = s.Store.UpsertMemoOrganizer(ctx, upsert)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find memo by ID: %v", memoID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo not found: %v", memoID))
|
||||||
|
}
|
||||||
|
|
||||||
|
memoResponse, err := s.convertMemoFromStore(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo response").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoResponse)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoRelationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MemoRelationReference MemoRelationType = "REFERENCE"
|
||||||
|
MemoRelationAdditional MemoRelationType = "ADDITIONAL"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoRelation struct {
|
||||||
|
MemoID int `json:"memoId"`
|
||||||
|
RelatedMemoID int `json:"relatedMemoId"`
|
||||||
|
Type MemoRelationType `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertMemoRelationRequest struct {
|
||||||
|
RelatedMemoID int `json:"relatedMemoId"`
|
||||||
|
Type MemoRelationType `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerMemoRelationRoutes(g *echo.Group) {
|
||||||
|
g.POST("/memo/:memoId/relation", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &UpsertMemoRelationRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo relation request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memoRelation, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||||||
|
MemoID: memoID,
|
||||||
|
RelatedMemoID: request.RelatedMemoID,
|
||||||
|
Type: store.MemoRelationType(request.Type),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoRelation)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/memo/:memoId/relation", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memoRelationList, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||||||
|
MemoID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list memo relations").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, memoRelationList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/memo/:memoId/relation/:relatedMemoId/type/:relationType", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
relatedMemoID, err := strconv.Atoi(c.Param("relatedMemoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Related memo ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
relationType := store.MemoRelationType(c.Param("relationType"))
|
||||||
|
|
||||||
|
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{
|
||||||
|
MemoID: &memoID,
|
||||||
|
RelatedMemoID: &relatedMemoID,
|
||||||
|
Type: &relationType,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertMemoRelationFromStore(memoRelation *store.MemoRelation) *MemoRelation {
|
||||||
|
return &MemoRelation{
|
||||||
|
MemoID: memoRelation.MemoID,
|
||||||
|
RelatedMemoID: memoRelation.RelatedMemoID,
|
||||||
|
Type: MemoRelationType(memoRelation.Type),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoResource struct {
|
||||||
|
MemoID int `json:"memoId"`
|
||||||
|
ResourceID int `json:"resourceId"`
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertMemoResourceRequest struct {
|
||||||
|
ResourceID int `json:"resourceId"`
|
||||||
|
UpdatedTs *int64 `json:"updatedTs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoResourceFind struct {
|
||||||
|
MemoID *int
|
||||||
|
ResourceID *int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoResourceDelete struct {
|
||||||
|
MemoID *int
|
||||||
|
ResourceID *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerMemoResourceRoutes(g *echo.Group) {
|
||||||
|
g.POST("/memo/:memoId/resource", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
request := &UpsertMemoResourceRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo resource request").SetInternal(err)
|
||||||
|
}
|
||||||
|
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||||
|
ID: &request.ResourceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
if resource == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Resource not found").SetInternal(err)
|
||||||
|
} else if resource.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to bind this resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert := &store.UpsertMemoResource{
|
||||||
|
MemoID: memoID,
|
||||||
|
ResourceID: request.ResourceID,
|
||||||
|
CreatedTs: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if request.UpdatedTs != nil {
|
||||||
|
upsert.UpdatedTs = request.UpdatedTs
|
||||||
|
}
|
||||||
|
if _, err := s.Store.UpsertMemoResource(ctx, upsert); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/memo/:memoId/resource", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListResources(ctx, &store.FindResource{
|
||||||
|
MemoID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||||
|
}
|
||||||
|
resourceList := []*Resource{}
|
||||||
|
for _, resource := range list {
|
||||||
|
resourceList = append(resourceList, convertResourceFromStore(resource))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, resourceList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/memo/:memoId/resource/:resourceId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
memoID, err := strconv.Atoi(c.Param("memoId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Memo ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Resource ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &memoID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo").SetInternal(err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Memo not found")
|
||||||
|
}
|
||||||
|
if memo.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Store.DeleteMemoResource(ctx, &store.DeleteMemoResource{
|
||||||
|
MemoID: &memoID,
|
||||||
|
ResourceID: &resourceID,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
echosse "github.com/CorrectRoadH/echo-sse"
|
||||||
|
"github.com/PullRequestInc/go-gpt3"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/plugin/openai"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerOpenAIRoutes(g *echo.Group) {
|
||||||
|
g.POST("/openai/chat-completion", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||||
|
Name: SystemSettingOpenAIConfigName.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
openAIConfig := OpenAIConfig{}
|
||||||
|
if openAIConfigSetting != nil {
|
||||||
|
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if openAIConfig.Key == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := []openai.ChatCompletionMessage{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := openai.PostChatCompletion(messages, openAIConfig.Key, openAIConfig.Host)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to post chat completion").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.POST("/openai/chat-streaming", func(c echo.Context) error {
|
||||||
|
messages := []gpt3.ChatCompletionRequestMessage{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(&messages); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post chat completion request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "No messages provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||||
|
Name: SystemSettingOpenAIConfigName.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
openAIConfig := OpenAIConfig{}
|
||||||
|
if openAIConfigSetting != nil {
|
||||||
|
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if openAIConfig.Key == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
sse := echosse.NewSSEClint(c)
|
||||||
|
|
||||||
|
// to do these things in server may not elegant.
|
||||||
|
// But move it to openai plugin will break the simple. Because it is a streaming. We must use a channel to do it.
|
||||||
|
// And we can think it is a forward proxy. So it in here is not a bad idea.
|
||||||
|
client := gpt3.NewClient(openAIConfig.Key)
|
||||||
|
err = client.ChatCompletionStream(ctx, gpt3.ChatCompletionRequest{
|
||||||
|
Model: gpt3.GPT3Dot5Turbo,
|
||||||
|
Messages: messages,
|
||||||
|
Stream: true,
|
||||||
|
},
|
||||||
|
func(resp *gpt3.ChatCompletionStreamResponse) {
|
||||||
|
// _ is for to pass the golangci-lint check
|
||||||
|
_ = sse.SendEvent(resp.Choices[0].Delta.Content)
|
||||||
|
|
||||||
|
// to delay 0.5 s
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
// the delay is a very good way to make the chatbot more comfortable
|
||||||
|
// otherwise the chatbot will reply too fast. Believe me it is not good.🤔
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to chat with OpenAI").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/openai/enabled", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
openAIConfigSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||||
|
Name: SystemSettingOpenAIConfigName.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find openai key").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
openAIConfig := OpenAIConfig{}
|
||||||
|
if openAIConfigSetting != nil {
|
||||||
|
err = json.Unmarshal([]byte(openAIConfigSetting.Value), &openAIConfig)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal openai system setting value").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if openAIConfig.Key == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "OpenAI API key not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, openAIConfig.Key != "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,671 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/common/log"
|
||||||
|
"github.com/usememos/memos/common/util"
|
||||||
|
"github.com/usememos/memos/plugin/storage/s3"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Resource struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
CreatorID int `json:"creatorId"`
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Blob []byte `json:"-"`
|
||||||
|
InternalPath string `json:"-"`
|
||||||
|
ExternalLink string `json:"externalLink"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
|
||||||
|
// Related fields
|
||||||
|
LinkedMemoAmount int `json:"linkedMemoAmount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateResourceRequest struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
InternalPath string `json:"internalPath"`
|
||||||
|
ExternalLink string `json:"externalLink"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DownloadToLocal bool `json:"downloadToLocal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindResourceRequest struct {
|
||||||
|
ID *int `json:"id"`
|
||||||
|
CreatorID *int `json:"creatorId"`
|
||||||
|
Filename *string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateResourceRequest struct {
|
||||||
|
Filename *string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The upload memory buffer is 32 MiB.
|
||||||
|
// It should be kept low, so RAM usage doesn't get out of control.
|
||||||
|
// This is unrelated to maximum upload size limit, which is now set through system setting.
|
||||||
|
maxUploadBufferSizeBytes = 32 << 20
|
||||||
|
MebiByte = 1024 * 1024
|
||||||
|
|
||||||
|
// thumbnailImagePath is the directory to store image thumbnails.
|
||||||
|
thumbnailImagePath = ".thumbnail_cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerResourceRoutes(g *echo.Group) {
|
||||||
|
g.POST("/resource", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &CreateResourceRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post resource request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
create := &store.Resource{
|
||||||
|
CreatorID: userID,
|
||||||
|
Filename: request.Filename,
|
||||||
|
ExternalLink: request.ExternalLink,
|
||||||
|
Type: request.Type,
|
||||||
|
}
|
||||||
|
if request.ExternalLink != "" {
|
||||||
|
// Only allow those external links scheme with http/https
|
||||||
|
linkURL, err := url.Parse(request.ExternalLink)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link").SetInternal(err)
|
||||||
|
}
|
||||||
|
if linkURL.Scheme != "http" && linkURL.Scheme != "https" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid external link scheme")
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.DownloadToLocal {
|
||||||
|
resp, err := http.Get(linkURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to request %s", request.ExternalLink))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
blob, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read %s", request.ExternalLink))
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Failed to read mime from %s", request.ExternalLink))
|
||||||
|
}
|
||||||
|
create.Type = mediaType
|
||||||
|
|
||||||
|
filename := path.Base(linkURL.Path)
|
||||||
|
if path.Ext(filename) == "" {
|
||||||
|
extensions, _ := mime.ExtensionsByType(mediaType)
|
||||||
|
if len(extensions) > 0 {
|
||||||
|
filename += extensions[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
create.Filename = filename
|
||||||
|
create.ExternalLink = ""
|
||||||
|
create.Size = int64(len(blob))
|
||||||
|
|
||||||
|
err = SaveResourceBlob(ctx, s.Store, create, bytes.NewReader(blob))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, err := s.Store.CreateResource(ctx, create)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.POST("/resource/blob", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the backend default max upload size limit.
|
||||||
|
maxUploadSetting := s.Store.GetSystemSettingValueWithDefault(&ctx, SystemSettingMaxUploadSizeMiBName.String(), "32")
|
||||||
|
var settingMaxUploadSizeBytes int
|
||||||
|
if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
|
||||||
|
settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
|
||||||
|
} else {
|
||||||
|
log.Warn("Failed to parse max upload size", zap.Error(err))
|
||||||
|
settingMaxUploadSizeBytes = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get uploading file").SetInternal(err)
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Size > int64(settingMaxUploadSizeBytes) {
|
||||||
|
message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte)
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceFile, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to open file").SetInternal(err)
|
||||||
|
}
|
||||||
|
defer sourceFile.Close()
|
||||||
|
|
||||||
|
create := &store.Resource{
|
||||||
|
CreatorID: userID,
|
||||||
|
Filename: file.Filename,
|
||||||
|
Type: file.Header.Get("Content-Type"),
|
||||||
|
Size: file.Size,
|
||||||
|
}
|
||||||
|
err = SaveResourceBlob(ctx, s.Store, create, sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, err := s.Store.CreateResource(ctx, create)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := s.createResourceCreateActivity(ctx, resource); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/resource", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
find := &store.FindResource{
|
||||||
|
CreatorID: &userID,
|
||||||
|
}
|
||||||
|
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
|
||||||
|
find.Limit = &limit
|
||||||
|
}
|
||||||
|
if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil {
|
||||||
|
find.Offset = &offset
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListResources(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||||
|
}
|
||||||
|
resourceMessageList := []*Resource{}
|
||||||
|
for _, resource := range list {
|
||||||
|
resourceMessageList = append(resourceMessageList, convertResourceFromStore(resource))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, resourceMessageList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.PATCH("/resource/:resourceId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||||
|
ID: &resourceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
if resource == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
|
||||||
|
}
|
||||||
|
if resource.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &UpdateResourceRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch resource request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTs := time.Now().Unix()
|
||||||
|
update := &store.UpdateResource{
|
||||||
|
ID: resourceID,
|
||||||
|
UpdatedTs: ¤tTs,
|
||||||
|
}
|
||||||
|
if request.Filename != nil && *request.Filename != "" {
|
||||||
|
update.Filename = request.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, err = s.Store.UpdateResource(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertResourceFromStore(resource))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||||
|
ID: &resourceID,
|
||||||
|
CreatorID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
if resource == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource.InternalPath != "" {
|
||||||
|
if err := os.Remove(resource.InternalPath); err != nil {
|
||||||
|
log.Warn(fmt.Sprintf("failed to delete local file with path %s", resource.InternalPath), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(resource.Filename)
|
||||||
|
thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
|
||||||
|
if err := os.Remove(thumbnailPath); err != nil {
|
||||||
|
log.Warn(fmt.Sprintf("failed to delete local thumbnail with path %s", thumbnailPath), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Store.DeleteResource(ctx, &store.DeleteResource{
|
||||||
|
ID: resourceID,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerResourcePublicRoutes(g *echo.Group) {
|
||||||
|
f := func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
resourceID, err := strconv.Atoi(c.Param("resourceId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceVisibility, err := checkResourceVisibility(ctx, s.Store, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Failed to get resource visibility").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected resource require a logined user
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if resourceVisibility == store.Protected && (!ok || userID <= 0) {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||||
|
ID: &resourceID,
|
||||||
|
GetBlob: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find resource by ID: %v", resourceID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if resource == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Resource not found: %d", resourceID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private resource require logined user is the creator
|
||||||
|
if resourceVisibility == store.Private && (!ok || userID != resource.CreatorID) {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Resource visibility not match").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := resource.Blob
|
||||||
|
if resource.InternalPath != "" {
|
||||||
|
resourcePath := resource.InternalPath
|
||||||
|
src, err := os.Open(resourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to open the local resource: %s", resourcePath)).SetInternal(err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
blob, err = io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to read the local resource: %s", resourcePath)).SetInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.QueryParam("thumbnail") == "1" && util.HasPrefixes(resource.Type, "image/png", "image/jpeg") {
|
||||||
|
ext := filepath.Ext(resource.Filename)
|
||||||
|
thumbnailPath := filepath.Join(s.Profile.Data, thumbnailImagePath, fmt.Sprintf("%d%s", resource.ID, ext))
|
||||||
|
thumbnailBlob, err := getOrGenerateThumbnailImage(blob, thumbnailPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(fmt.Sprintf("failed to get or generate local thumbnail with path %s", thumbnailPath), zap.Error(err))
|
||||||
|
} else {
|
||||||
|
blob = thumbnailBlob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Writer.Header().Set(echo.HeaderCacheControl, "max-age=31536000, immutable")
|
||||||
|
c.Response().Writer.Header().Set(echo.HeaderContentSecurityPolicy, "default-src 'self'")
|
||||||
|
resourceType := strings.ToLower(resource.Type)
|
||||||
|
if strings.HasPrefix(resourceType, "text") {
|
||||||
|
resourceType = echo.MIMETextPlainCharsetUTF8
|
||||||
|
} else if strings.HasPrefix(resourceType, "video") || strings.HasPrefix(resourceType, "audio") {
|
||||||
|
http.ServeContent(c.Response(), c.Request(), resource.Filename, time.Unix(resource.UpdatedTs, 0), bytes.NewReader(blob))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.Stream(http.StatusOK, resourceType, bytes.NewReader(blob))
|
||||||
|
}
|
||||||
|
|
||||||
|
g.GET("/r/:resourceId", f)
|
||||||
|
g.GET("/r/:resourceId/*", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createResourceCreateActivity(ctx context.Context, resource *store.Resource) error {
|
||||||
|
payload := ActivityResourceCreatePayload{
|
||||||
|
Filename: resource.Filename,
|
||||||
|
Type: resource.Type,
|
||||||
|
Size: resource.Size,
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||||
|
CreatorID: resource.CreatorID,
|
||||||
|
Type: ActivityResourceCreate.String(),
|
||||||
|
Level: ActivityInfo.String(),
|
||||||
|
Payload: string(payloadBytes),
|
||||||
|
})
|
||||||
|
if err != nil || activity == nil {
|
||||||
|
return errors.Wrap(err, "failed to create activity")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePathTemplate(path, filename string) string {
|
||||||
|
t := time.Now()
|
||||||
|
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "{filename}":
|
||||||
|
return filename
|
||||||
|
case "{timestamp}":
|
||||||
|
return fmt.Sprintf("%d", t.Unix())
|
||||||
|
case "{year}":
|
||||||
|
return fmt.Sprintf("%d", t.Year())
|
||||||
|
case "{month}":
|
||||||
|
return fmt.Sprintf("%02d", t.Month())
|
||||||
|
case "{day}":
|
||||||
|
return fmt.Sprintf("%02d", t.Day())
|
||||||
|
case "{hour}":
|
||||||
|
return fmt.Sprintf("%02d", t.Hour())
|
||||||
|
case "{minute}":
|
||||||
|
return fmt.Sprintf("%02d", t.Minute())
|
||||||
|
case "{second}":
|
||||||
|
return fmt.Sprintf("%02d", t.Second())
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableGeneratorAmount int32 = 32
|
||||||
|
|
||||||
|
func getOrGenerateThumbnailImage(srcBlob []byte, dstPath string) ([]byte, error) {
|
||||||
|
if _, err := os.Stat(dstPath); err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
|
||||||
|
}
|
||||||
|
|
||||||
|
if atomic.LoadInt32(&availableGeneratorAmount) <= 0 {
|
||||||
|
return nil, errors.New("not enough available generator amount")
|
||||||
|
}
|
||||||
|
atomic.AddInt32(&availableGeneratorAmount, -1)
|
||||||
|
defer func() {
|
||||||
|
atomic.AddInt32(&availableGeneratorAmount, 1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
reader := bytes.NewReader(srcBlob)
|
||||||
|
src, err := imaging.Decode(reader, imaging.AutoOrientation(true))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to decode thumbnail image")
|
||||||
|
}
|
||||||
|
thumbnailImage := imaging.Resize(src, 512, 0, imaging.Lanczos)
|
||||||
|
|
||||||
|
dstDir := path.Dir(dstPath)
|
||||||
|
if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to create thumbnail dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := imaging.Save(thumbnailImage, dstPath); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to resize thumbnail image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dstFile, err := os.Open(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to open the local resource")
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
dstBlob, err := io.ReadAll(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to read the local resource")
|
||||||
|
}
|
||||||
|
return dstBlob, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkResourceVisibility(ctx context.Context, s *store.Store, resourceID int) (store.Visibility, error) {
|
||||||
|
memoResources, err := s.ListMemoResources(ctx, &store.FindMemoResource{
|
||||||
|
ResourceID: &resourceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return store.Private, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If resource is belongs to no memo, it'll always PRIVATE.
|
||||||
|
if len(memoResources) == 0 {
|
||||||
|
return store.Private, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
memoIDs := make([]int, 0, len(memoResources))
|
||||||
|
for _, memoResource := range memoResources {
|
||||||
|
memoIDs = append(memoIDs, memoResource.MemoID)
|
||||||
|
}
|
||||||
|
visibilityList, err := s.FindMemosVisibilityList(ctx, memoIDs)
|
||||||
|
if err != nil {
|
||||||
|
return store.Private, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var isProtected bool
|
||||||
|
for _, visibility := range visibilityList {
|
||||||
|
// If any memo is PUBLIC, resource should be PUBLIC too.
|
||||||
|
if visibility == store.Public {
|
||||||
|
return store.Public, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if visibility == store.Protected {
|
||||||
|
isProtected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isProtected {
|
||||||
|
return store.Protected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.Private, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertResourceFromStore(resource *store.Resource) *Resource {
|
||||||
|
return &Resource{
|
||||||
|
ID: resource.ID,
|
||||||
|
CreatorID: resource.CreatorID,
|
||||||
|
CreatedTs: resource.CreatedTs,
|
||||||
|
UpdatedTs: resource.UpdatedTs,
|
||||||
|
Filename: resource.Filename,
|
||||||
|
Blob: resource.Blob,
|
||||||
|
InternalPath: resource.InternalPath,
|
||||||
|
ExternalLink: resource.ExternalLink,
|
||||||
|
Type: resource.Type,
|
||||||
|
Size: resource.Size,
|
||||||
|
LinkedMemoAmount: resource.LinkedMemoAmount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveResourceBlob save the blob of resource based on the storage config
|
||||||
|
//
|
||||||
|
// Depend on the storage config, some fields of *store.ResourceCreate will be changed:
|
||||||
|
// 1. *DatabaseStorage*: `create.Blob`.
|
||||||
|
// 2. *LocalStorage*: `create.InternalPath`.
|
||||||
|
// 3. Others( external service): `create.ExternalLink`.
|
||||||
|
func SaveResourceBlob(ctx context.Context, s *store.Store, create *store.Resource, r io.Reader) error {
|
||||||
|
systemSettingStorageServiceID, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to find SystemSettingStorageServiceIDName: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storageServiceID := DatabaseStorage
|
||||||
|
if systemSettingStorageServiceID != nil {
|
||||||
|
err = json.Unmarshal([]byte(systemSettingStorageServiceID.Value), &storageServiceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to unmarshal storage service id: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `DatabaseStorage` means store blob into database
|
||||||
|
if storageServiceID == DatabaseStorage {
|
||||||
|
fileBytes, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to read file: %s", err)
|
||||||
|
}
|
||||||
|
create.Blob = fileBytes
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// `LocalStorage` means save blob into local disk
|
||||||
|
if storageServiceID == LocalStorage {
|
||||||
|
systemSettingLocalStoragePath, err := s.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingLocalStoragePathName.String()})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to find SystemSettingLocalStoragePathName: %s", err)
|
||||||
|
}
|
||||||
|
localStoragePath := "assets/{filename}"
|
||||||
|
if systemSettingLocalStoragePath != nil && systemSettingLocalStoragePath.Value != "" {
|
||||||
|
err = json.Unmarshal([]byte(systemSettingLocalStoragePath.Value), &localStoragePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to unmarshal SystemSettingLocalStoragePathName: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filePath := filepath.FromSlash(localStoragePath)
|
||||||
|
if !strings.Contains(filePath, "{filename}") {
|
||||||
|
filePath = filepath.Join(filePath, "{filename}")
|
||||||
|
}
|
||||||
|
filePath = filepath.Join(s.Profile.Data, replacePathTemplate(filePath, create.Filename))
|
||||||
|
|
||||||
|
dir := filepath.Dir(filePath)
|
||||||
|
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||||
|
return fmt.Errorf("Failed to create directory: %s", err)
|
||||||
|
}
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to create file: %s", err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
_, err = io.Copy(dst, r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to copy file: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
create.InternalPath = filePath
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Others: store blob into external service, such as S3
|
||||||
|
storage, err := s.GetStorage(ctx, &store.FindStorage{ID: &storageServiceID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to find StorageServiceID: %s", err)
|
||||||
|
}
|
||||||
|
if storage == nil {
|
||||||
|
return fmt.Errorf("Storage %d not found", storageServiceID)
|
||||||
|
}
|
||||||
|
storageMessage, err := ConvertStorageFromStore(storage)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to ConvertStorageFromStore: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if storageMessage.Type != StorageS3 {
|
||||||
|
return fmt.Errorf("Unsupported storage type: %s", storageMessage.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Config := storageMessage.Config.S3Config
|
||||||
|
s3Client, err := s3.NewClient(ctx, &s3.Config{
|
||||||
|
AccessKey: s3Config.AccessKey,
|
||||||
|
SecretKey: s3Config.SecretKey,
|
||||||
|
EndPoint: s3Config.EndPoint,
|
||||||
|
Region: s3Config.Region,
|
||||||
|
Bucket: s3Config.Bucket,
|
||||||
|
URLPrefix: s3Config.URLPrefix,
|
||||||
|
URLSuffix: s3Config.URLSuffix,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to create s3 client: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := s3Config.Path
|
||||||
|
if !strings.Contains(filePath, "{filename}") {
|
||||||
|
filePath = filepath.Join(filePath, "{filename}")
|
||||||
|
}
|
||||||
|
filePath = replacePathTemplate(filePath, create.Filename)
|
||||||
|
|
||||||
|
link, err := s3Client.UploadFile(ctx, filePath, create.Type, r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to upload via s3 client: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
create.ExternalLink = link
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/feeds"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/common/util"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRSSItemCount = 100
|
||||||
|
const maxRSSItemTitleLength = 100
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerRSSRoutes(g *echo.Group) {
|
||||||
|
g.GET("/explore/rss.xml", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalStatus := store.Normal
|
||||||
|
memoFind := store.FindMemo{
|
||||||
|
RowStatus: &normalStatus,
|
||||||
|
VisibilityList: []store.Visibility{store.Public},
|
||||||
|
}
|
||||||
|
memoList, err := s.Store.ListMemos(ctx, &memoFind)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||||
|
rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||||
|
}
|
||||||
|
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
|
||||||
|
return c.String(http.StatusOK, rss)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/u/:id/rss.xml", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "User id is not a number").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemCustomizedProfile, err := s.getSystemCustomizedProfile(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get system customized profile").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalStatus := store.Normal
|
||||||
|
memoFind := store.FindMemo{
|
||||||
|
CreatorID: &id,
|
||||||
|
RowStatus: &normalStatus,
|
||||||
|
VisibilityList: []store.Visibility{store.Public},
|
||||||
|
}
|
||||||
|
memoList, err := s.Store.ListMemos(ctx, &memoFind)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := c.Scheme() + "://" + c.Request().Host
|
||||||
|
rss, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, systemCustomizedProfile)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").SetInternal(err)
|
||||||
|
}
|
||||||
|
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
|
||||||
|
return c.String(http.StatusOK, rss)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) generateRSSFromMemoList(ctx context.Context, memoList []*store.Memo, baseURL string, profile *CustomizedProfile) (string, error) {
|
||||||
|
feed := &feeds.Feed{
|
||||||
|
Title: profile.Name,
|
||||||
|
Link: &feeds.Link{Href: baseURL},
|
||||||
|
Description: profile.Description,
|
||||||
|
Created: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemCountLimit = util.Min(len(memoList), maxRSSItemCount)
|
||||||
|
feed.Items = make([]*feeds.Item, itemCountLimit)
|
||||||
|
for i := 0; i < itemCountLimit; i++ {
|
||||||
|
memo := memoList[i]
|
||||||
|
feed.Items[i] = &feeds.Item{
|
||||||
|
Title: getRSSItemTitle(memo.Content),
|
||||||
|
Link: &feeds.Link{Href: baseURL + "/m/" + strconv.Itoa(memo.ID)},
|
||||||
|
Description: getRSSItemDescription(memo.Content),
|
||||||
|
Created: time.Unix(memo.CreatedTs, 0),
|
||||||
|
Enclosure: &feeds.Enclosure{Url: baseURL + "/m/" + strconv.Itoa(memo.ID) + "/image"},
|
||||||
|
}
|
||||||
|
if len(memo.ResourceIDList) > 0 {
|
||||||
|
resourceID := memo.ResourceIDList[0]
|
||||||
|
resource, err := s.Store.GetResource(ctx, &store.FindResource{
|
||||||
|
ID: &resourceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resource == nil {
|
||||||
|
return "", fmt.Errorf("Resource not found: %d", resourceID)
|
||||||
|
}
|
||||||
|
enclosure := feeds.Enclosure{}
|
||||||
|
if resource.ExternalLink != "" {
|
||||||
|
enclosure.Url = resource.ExternalLink
|
||||||
|
} else {
|
||||||
|
enclosure.Url = baseURL + "/o/r/" + strconv.Itoa(resource.ID)
|
||||||
|
}
|
||||||
|
enclosure.Length = strconv.Itoa(int(resource.Size))
|
||||||
|
enclosure.Type = resource.Type
|
||||||
|
feed.Items[i].Enclosure = &enclosure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rss, err := feed.ToRss()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return rss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) getSystemCustomizedProfile(ctx context.Context) (*CustomizedProfile, error) {
|
||||||
|
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{
|
||||||
|
Name: SystemSettingCustomizedProfileName.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
customizedProfile := &CustomizedProfile{
|
||||||
|
Name: "memos",
|
||||||
|
LogoURL: "",
|
||||||
|
Description: "",
|
||||||
|
Locale: "en",
|
||||||
|
Appearance: "system",
|
||||||
|
ExternalURL: "",
|
||||||
|
}
|
||||||
|
if systemSetting != nil {
|
||||||
|
if err := json.Unmarshal([]byte(systemSetting.Value), customizedProfile); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return customizedProfile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRSSItemTitle(content string) string {
|
||||||
|
var title string
|
||||||
|
if isTitleDefined(content) {
|
||||||
|
title = strings.Split(content, "\n")[0][2:]
|
||||||
|
} else {
|
||||||
|
title = strings.Split(content, "\n")[0]
|
||||||
|
var titleLengthLimit = util.Min(len(title), maxRSSItemTitleLength)
|
||||||
|
if titleLengthLimit < len(title) {
|
||||||
|
title = title[:titleLengthLimit] + "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRSSItemDescription(content string) string {
|
||||||
|
var description string
|
||||||
|
if isTitleDefined(content) {
|
||||||
|
var firstLineEnd = strings.Index(content, "\n")
|
||||||
|
description = strings.Trim(content[firstLineEnd+1:], " ")
|
||||||
|
} else {
|
||||||
|
description = content
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: use our `./plugin/gomark` parser to handle markdown-like content.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := goldmark.Convert([]byte(description), &buf); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTitleDefined(content string) bool {
|
||||||
|
return strings.HasPrefix(content, "# ")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Shortcut struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
RowStatus RowStatus `json:"rowStatus"`
|
||||||
|
CreatorID int `json:"creatorId"`
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Title string `json:"title"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateShortcutRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateShortcutRequest struct {
|
||||||
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Payload *string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortcutFind struct {
|
||||||
|
ID *int
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
CreatorID *int
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Title *string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortcutDelete struct {
|
||||||
|
ID *int
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
CreatorID *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||||
|
g.POST("/shortcut", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
shortcutCreate := &CreateShortcutRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(shortcutCreate); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err := s.Store.CreateShortcut(ctx, &store.Shortcut{
|
||||||
|
CreatorID: userID,
|
||||||
|
Title: shortcutCreate.Title,
|
||||||
|
Payload: shortcutCreate.Payload,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutMessage := convertShortcutFromStore(shortcut)
|
||||||
|
if err := s.createShortcutCreateActivity(c, shortcutMessage); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, shortcutMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &shortcutID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
|
||||||
|
}
|
||||||
|
if shortcut.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &UpdateShortcutRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTs := time.Now().Unix()
|
||||||
|
shortcutUpdate := &store.UpdateShortcut{
|
||||||
|
ID: shortcutID,
|
||||||
|
UpdatedTs: ¤tTs,
|
||||||
|
}
|
||||||
|
if request.RowStatus != nil {
|
||||||
|
rowStatus := store.RowStatus(*request.RowStatus)
|
||||||
|
shortcutUpdate.RowStatus = &rowStatus
|
||||||
|
}
|
||||||
|
if request.Title != nil {
|
||||||
|
shortcutUpdate.Title = request.Title
|
||||||
|
}
|
||||||
|
if request.Payload != nil {
|
||||||
|
shortcutUpdate.Payload = request.Payload
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertShortcutFromStore(shortcut))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/shortcut", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find shortcut")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListShortcuts(ctx, &store.FindShortcut{
|
||||||
|
CreatorID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get shortcut list").SetInternal(err)
|
||||||
|
}
|
||||||
|
shortcutMessageList := make([]*Shortcut, 0, len(list))
|
||||||
|
for _, shortcut := range list {
|
||||||
|
shortcutMessageList = append(shortcutMessageList, convertShortcutFromStore(shortcut))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, shortcutMessageList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &shortcutID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch shortcut by ID %d", shortcutID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertShortcutFromStore(shortcut))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &shortcutID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find shortcut").SetInternal(err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Shortcut not found: %d", shortcutID))
|
||||||
|
}
|
||||||
|
if shortcut.CreatorID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||||
|
ID: &shortcutID,
|
||||||
|
}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createShortcutCreateActivity(c echo.Context, shortcut *Shortcut) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
payload := ActivityShortcutCreatePayload{
|
||||||
|
Title: shortcut.Title,
|
||||||
|
Payload: shortcut.Payload,
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||||
|
CreatorID: shortcut.CreatorID,
|
||||||
|
Type: ActivityShortcutCreate.String(),
|
||||||
|
Level: ActivityInfo.String(),
|
||||||
|
Payload: string(payloadBytes),
|
||||||
|
})
|
||||||
|
if err != nil || activity == nil {
|
||||||
|
return errors.Wrap(err, "failed to create activity")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
||||||
|
return &Shortcut{
|
||||||
|
ID: shortcut.ID,
|
||||||
|
RowStatus: RowStatus(shortcut.RowStatus),
|
||||||
|
CreatorID: shortcut.CreatorID,
|
||||||
|
Title: shortcut.Title,
|
||||||
|
Payload: shortcut.Payload,
|
||||||
|
CreatedTs: shortcut.CreatedTs,
|
||||||
|
UpdatedTs: shortcut.UpdatedTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LocalStorage means the storage service is local file system.
|
||||||
|
LocalStorage = -1
|
||||||
|
// DatabaseStorage means the storage service is database.
|
||||||
|
DatabaseStorage = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
type StorageType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StorageS3 StorageType = "S3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t StorageType) String() string {
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageConfig struct {
|
||||||
|
S3Config *StorageS3Config `json:"s3Config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageS3Config struct {
|
||||||
|
EndPoint string `json:"endPoint"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
AccessKey string `json:"accessKey"`
|
||||||
|
SecretKey string `json:"secretKey"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
URLPrefix string `json:"urlPrefix"`
|
||||||
|
URLSuffix string `json:"urlSuffix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Storage struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type StorageType `json:"type"`
|
||||||
|
Config *StorageConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateStorageRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type StorageType `json:"type"`
|
||||||
|
Config *StorageConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateStorageRequest struct {
|
||||||
|
Type StorageType `json:"type"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Config *StorageConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerStorageRoutes(g *echo.Group) {
|
||||||
|
g.POST("/storage", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
create := &CreateStorageRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configString := ""
|
||||||
|
if create.Type == StorageS3 && create.Config.S3Config != nil {
|
||||||
|
configBytes, err := json.Marshal(create.Config.S3Config)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
|
||||||
|
}
|
||||||
|
configString = string(configBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage, err := s.Store.CreateStorage(ctx, &store.Storage{
|
||||||
|
Name: create.Name,
|
||||||
|
Type: create.Type.String(),
|
||||||
|
Config: configString,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create storage").SetInternal(err)
|
||||||
|
}
|
||||||
|
storageMessage, err := ConvertStorageFromStore(storage)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, storageMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.PATCH("/storage/:storageId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
storageID, err := strconv.Atoi(c.Param("storageId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
update := &UpdateStorageRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(update); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch storage request").SetInternal(err)
|
||||||
|
}
|
||||||
|
storageUpdate := &store.UpdateStorage{
|
||||||
|
ID: storageID,
|
||||||
|
}
|
||||||
|
if update.Name != nil {
|
||||||
|
storageUpdate.Name = update.Name
|
||||||
|
}
|
||||||
|
if update.Config != nil {
|
||||||
|
if update.Type == StorageS3 {
|
||||||
|
configBytes, err := json.Marshal(update.Config.S3Config)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post storage request").SetInternal(err)
|
||||||
|
}
|
||||||
|
configString := string(configBytes)
|
||||||
|
storageUpdate.Config = &configString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage, err := s.Store.UpdateStorage(ctx, storageUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch storage").SetInternal(err)
|
||||||
|
}
|
||||||
|
storageMessage, err := ConvertStorageFromStore(storage)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, storageMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/storage", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
// We should only show storage list to host user.
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListStorages(ctx, &store.FindStorage{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storageList := []*Storage{}
|
||||||
|
for _, storage := range list {
|
||||||
|
storageMessage, err := ConvertStorageFromStore(storage)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to convert storage").SetInternal(err)
|
||||||
|
}
|
||||||
|
storageList = append(storageList, storageMessage)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, storageList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.DELETE("/storage/:storageId", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
storageID, err := strconv.Atoi(c.Param("storageId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("storageId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemSetting, err := s.Store.GetSystemSetting(ctx, &store.FindSystemSetting{Name: SystemSettingStorageServiceIDName.String()})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find storage").SetInternal(err)
|
||||||
|
}
|
||||||
|
if systemSetting != nil {
|
||||||
|
storageServiceID := DatabaseStorage
|
||||||
|
err = json.Unmarshal([]byte(systemSetting.Value), &storageServiceID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal storage service id").SetInternal(err)
|
||||||
|
}
|
||||||
|
if storageServiceID == storageID {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Storage service %d is using", storageID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.Store.DeleteStorage(ctx, &store.DeleteStorage{ID: storageID}); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete storage").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertStorageFromStore(storage *store.Storage) (*Storage, error) {
|
||||||
|
storageMessage := &Storage{
|
||||||
|
ID: storage.ID,
|
||||||
|
Name: storage.Name,
|
||||||
|
Type: StorageType(storage.Type),
|
||||||
|
Config: &StorageConfig{},
|
||||||
|
}
|
||||||
|
if storageMessage.Type == StorageS3 {
|
||||||
|
s3Config := &StorageS3Config{}
|
||||||
|
if err := json.Unmarshal([]byte(storage.Config), s3Config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
storageMessage.Config = &StorageConfig{
|
||||||
|
S3Config: s3Config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storageMessage, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/common/log"
|
||||||
|
"github.com/usememos/memos/server/profile"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SystemStatus struct {
|
||||||
|
Host *User `json:"host"`
|
||||||
|
Profile profile.Profile `json:"profile"`
|
||||||
|
DBSize int64 `json:"dbSize"`
|
||||||
|
|
||||||
|
// System settings
|
||||||
|
// Allow sign up.
|
||||||
|
AllowSignUp bool `json:"allowSignUp"`
|
||||||
|
// Disable public memos.
|
||||||
|
DisablePublicMemos bool `json:"disablePublicMemos"`
|
||||||
|
// Max upload size.
|
||||||
|
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
|
||||||
|
// Additional style.
|
||||||
|
AdditionalStyle string `json:"additionalStyle"`
|
||||||
|
// Additional script.
|
||||||
|
AdditionalScript string `json:"additionalScript"`
|
||||||
|
// Customized server profile, including server name and external url.
|
||||||
|
CustomizedProfile CustomizedProfile `json:"customizedProfile"`
|
||||||
|
// Storage service ID.
|
||||||
|
StorageServiceID int `json:"storageServiceId"`
|
||||||
|
// Local storage path.
|
||||||
|
LocalStoragePath string `json:"localStoragePath"`
|
||||||
|
// Memo display with updated timestamp.
|
||||||
|
MemoDisplayWithUpdatedTs bool `json:"memoDisplayWithUpdatedTs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerSystemRoutes(g *echo.Group) {
|
||||||
|
g.GET("/ping", func(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, s.Profile)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/status", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
systemStatus := SystemStatus{
|
||||||
|
Profile: *s.Profile,
|
||||||
|
DBSize: 0,
|
||||||
|
AllowSignUp: false,
|
||||||
|
DisablePublicMemos: false,
|
||||||
|
MaxUploadSizeMiB: 32,
|
||||||
|
AdditionalStyle: "",
|
||||||
|
AdditionalScript: "",
|
||||||
|
CustomizedProfile: CustomizedProfile{
|
||||||
|
Name: "memos",
|
||||||
|
LogoURL: "",
|
||||||
|
Description: "",
|
||||||
|
Locale: "en",
|
||||||
|
Appearance: "system",
|
||||||
|
ExternalURL: "",
|
||||||
|
},
|
||||||
|
StorageServiceID: DatabaseStorage,
|
||||||
|
LocalStoragePath: "assets/{timestamp}_{filename}",
|
||||||
|
MemoDisplayWithUpdatedTs: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
hostUserType := store.RoleHost
|
||||||
|
hostUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
Role: &hostUserType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if hostUser != nil {
|
||||||
|
systemStatus.Host = convertUserFromStore(hostUser)
|
||||||
|
// data desensitize
|
||||||
|
systemStatus.Host.OpenID = ""
|
||||||
|
systemStatus.Host.Email = ""
|
||||||
|
systemStatus.Host.AvatarURL = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
systemSettingList, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||||
|
}
|
||||||
|
for _, systemSetting := range systemSettingList {
|
||||||
|
if systemSetting.Name == SystemSettingServerIDName.String() || systemSetting.Name == SystemSettingSecretSessionName.String() || systemSetting.Name == SystemSettingTelegramBotTokenName.String() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseValue any
|
||||||
|
err := json.Unmarshal([]byte(systemSetting.Value), &baseValue)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to unmarshal system setting value", zap.String("setting name", systemSetting.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch systemSetting.Name {
|
||||||
|
case SystemSettingAllowSignUpName.String():
|
||||||
|
systemStatus.AllowSignUp = baseValue.(bool)
|
||||||
|
case SystemSettingDisablePublicMemosName.String():
|
||||||
|
systemStatus.DisablePublicMemos = baseValue.(bool)
|
||||||
|
case SystemSettingMaxUploadSizeMiBName.String():
|
||||||
|
systemStatus.MaxUploadSizeMiB = int(baseValue.(float64))
|
||||||
|
case SystemSettingAdditionalStyleName.String():
|
||||||
|
systemStatus.AdditionalStyle = baseValue.(string)
|
||||||
|
case SystemSettingAdditionalScriptName.String():
|
||||||
|
systemStatus.AdditionalScript = baseValue.(string)
|
||||||
|
case SystemSettingCustomizedProfileName.String():
|
||||||
|
customizedProfile := CustomizedProfile{}
|
||||||
|
if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
|
||||||
|
}
|
||||||
|
systemStatus.CustomizedProfile = customizedProfile
|
||||||
|
case SystemSettingStorageServiceIDName.String():
|
||||||
|
systemStatus.StorageServiceID = int(baseValue.(float64))
|
||||||
|
case SystemSettingLocalStoragePathName.String():
|
||||||
|
systemStatus.LocalStoragePath = baseValue.(string)
|
||||||
|
case SystemSettingMemoDisplayWithUpdatedTsName.String():
|
||||||
|
systemStatus.MemoDisplayWithUpdatedTs = baseValue.(bool)
|
||||||
|
default:
|
||||||
|
log.Warn("Unknown system setting name", zap.String("setting name", systemSetting.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, systemStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.POST("/system/vacuum", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Store.Vacuum(ctx); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to vacuum database").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SystemSettingName string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SystemSettingServerIDName is the name of server id.
|
||||||
|
SystemSettingServerIDName SystemSettingName = "server-id"
|
||||||
|
// SystemSettingSecretSessionName is the name of secret session.
|
||||||
|
SystemSettingSecretSessionName SystemSettingName = "secret-session"
|
||||||
|
// SystemSettingAllowSignUpName is the name of allow signup setting.
|
||||||
|
SystemSettingAllowSignUpName SystemSettingName = "allow-signup"
|
||||||
|
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
|
||||||
|
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
|
||||||
|
// SystemSettingMaxUploadSizeMiBName is the name of max upload size setting.
|
||||||
|
SystemSettingMaxUploadSizeMiBName SystemSettingName = "max-upload-size-mib"
|
||||||
|
// SystemSettingAdditionalStyleName is the name of additional style.
|
||||||
|
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
|
||||||
|
// SystemSettingAdditionalScriptName is the name of additional script.
|
||||||
|
SystemSettingAdditionalScriptName SystemSettingName = "additional-script"
|
||||||
|
// SystemSettingCustomizedProfileName is the name of customized server profile.
|
||||||
|
SystemSettingCustomizedProfileName SystemSettingName = "customized-profile"
|
||||||
|
// SystemSettingStorageServiceIDName is the name of storage service ID.
|
||||||
|
SystemSettingStorageServiceIDName SystemSettingName = "storage-service-id"
|
||||||
|
// SystemSettingLocalStoragePathName is the name of local storage path.
|
||||||
|
SystemSettingLocalStoragePathName SystemSettingName = "local-storage-path"
|
||||||
|
// SystemSettingTelegramBotToken is the name of Telegram Bot Token.
|
||||||
|
SystemSettingTelegramBotTokenName SystemSettingName = "telegram-bot-token"
|
||||||
|
// SystemSettingMemoDisplayWithUpdatedTsName is the name of memo display with updated ts.
|
||||||
|
SystemSettingMemoDisplayWithUpdatedTsName SystemSettingName = "memo-display-with-updated-ts"
|
||||||
|
// SystemSettingOpenAIConfigName is the name of OpenAI config.
|
||||||
|
SystemSettingOpenAIConfigName SystemSettingName = "openai-config"
|
||||||
|
// SystemSettingAutoBackupIntervalName is the name of auto backup interval as seconds.
|
||||||
|
SystemSettingAutoBackupIntervalName SystemSettingName = "auto-backup-interval"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomizedProfile is the struct definition for SystemSettingCustomizedProfileName system setting item.
|
||||||
|
type CustomizedProfile struct {
|
||||||
|
// Name is the server name, default is `memos`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// LogoURL is the url of logo image.
|
||||||
|
LogoURL string `json:"logoUrl"`
|
||||||
|
// Description is the server description.
|
||||||
|
Description string `json:"description"`
|
||||||
|
// Locale is the server default locale.
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
// Appearance is the server default appearance.
|
||||||
|
Appearance string `json:"appearance"`
|
||||||
|
// ExternalURL is the external url of server. e.g. https://usermemos.com
|
||||||
|
ExternalURL string `json:"externalUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key SystemSettingName) String() string {
|
||||||
|
return string(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemSetting struct {
|
||||||
|
Name SystemSettingName `json:"name"`
|
||||||
|
// Value is a JSON string with basic value.
|
||||||
|
Value string `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAIConfig struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertSystemSettingRequest struct {
|
||||||
|
Name SystemSettingName `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
|
||||||
|
|
||||||
|
func (upsert UpsertSystemSettingRequest) Validate() error {
|
||||||
|
switch settingName := upsert.Name; settingName {
|
||||||
|
case SystemSettingServerIDName:
|
||||||
|
return fmt.Errorf("updating %v is not allowed", settingName)
|
||||||
|
case SystemSettingAllowSignUpName:
|
||||||
|
var value bool
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingDisablePublicMemosName:
|
||||||
|
var value bool
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingMaxUploadSizeMiBName:
|
||||||
|
var value int
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingAdditionalStyleName:
|
||||||
|
var value string
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingAdditionalScriptName:
|
||||||
|
var value string
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingCustomizedProfileName:
|
||||||
|
customizedProfile := CustomizedProfile{
|
||||||
|
Name: "memos",
|
||||||
|
LogoURL: "",
|
||||||
|
Description: "",
|
||||||
|
Locale: "en",
|
||||||
|
Appearance: "system",
|
||||||
|
ExternalURL: "",
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &customizedProfile); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingStorageServiceIDName:
|
||||||
|
// Note: 0 is the default value(database) for storage service ID.
|
||||||
|
value := 0
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case SystemSettingLocalStoragePathName:
|
||||||
|
value := ""
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingOpenAIConfigName:
|
||||||
|
value := OpenAIConfig{}
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingAutoBackupIntervalName:
|
||||||
|
var value string
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
if value != "" {
|
||||||
|
v, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
if v < 0 {
|
||||||
|
return fmt.Errorf("backup interval should > 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case SystemSettingTelegramBotTokenName:
|
||||||
|
if upsert.Value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Bot Token with Reverse Proxy shoule like `http.../bot<token>`
|
||||||
|
if strings.HasPrefix(upsert.Value, "http") {
|
||||||
|
slashIndex := strings.LastIndexAny(upsert.Value, "/")
|
||||||
|
if strings.HasPrefix(upsert.Value[slashIndex:], "/bot") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("token start with `http` must end with `/bot<token>`")
|
||||||
|
}
|
||||||
|
fragments := strings.Split(upsert.Value, ":")
|
||||||
|
if len(fragments) != 2 {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
case SystemSettingMemoDisplayWithUpdatedTsName:
|
||||||
|
var value bool
|
||||||
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid system setting name")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerSystemSettingRoutes(g *echo.Group) {
|
||||||
|
g.POST("/system/setting", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
systemSettingUpsert := &UpsertSystemSettingRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := systemSettingUpsert.Validate(); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid system setting").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemSetting, err := s.Store.UpsertSystemSetting(ctx, &store.SystemSetting{
|
||||||
|
Name: systemSettingUpsert.Name.String(),
|
||||||
|
Value: systemSettingUpsert.Value,
|
||||||
|
Description: systemSettingUpsert.Description,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, convertSystemSettingFromStore(systemSetting))
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/system/setting", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil || user.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListSystemSettings(ctx, &store.FindSystemSetting{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemSettingList := make([]*SystemSetting, 0, len(list))
|
||||||
|
for _, systemSetting := range list {
|
||||||
|
systemSettingList = append(systemSettingList, convertSystemSettingFromStore(systemSetting))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, systemSettingList)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertSystemSettingFromStore(systemSetting *store.SystemSetting) *SystemSetting {
|
||||||
|
return &SystemSetting{
|
||||||
|
Name: SystemSettingName(systemSetting.Name),
|
||||||
|
Value: systemSetting.Value,
|
||||||
|
Description: systemSetting.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
Name string
|
||||||
|
CreatorID int
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertTagRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteTagRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerTagRoutes(g *echo.Group) {
|
||||||
|
g.POST("/tag", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagUpsert := &UpsertTagRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if tagUpsert.Name == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := s.Store.UpsertTag(ctx, &store.Tag{
|
||||||
|
Name: tagUpsert.Name,
|
||||||
|
CreatorID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
|
||||||
|
}
|
||||||
|
tagMessage := convertTagFromStore(tag)
|
||||||
|
if err := s.createTagCreateActivity(c, tagMessage); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, tagMessage.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/tag", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListTags(ctx, &store.FindTag{
|
||||||
|
CreatorID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagNameList := []string{}
|
||||||
|
for _, tag := range list {
|
||||||
|
tagNameList = append(tagNameList, tag.Name)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, tagNameList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/tag/suggestion", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user session")
|
||||||
|
}
|
||||||
|
normalRowStatus := store.Normal
|
||||||
|
memoFind := &store.FindMemo{
|
||||||
|
CreatorID: &userID,
|
||||||
|
ContentSearch: []string{"#"},
|
||||||
|
RowStatus: &normalRowStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
memoMessageList, err := s.Store.ListMemos(ctx, memoFind)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListTags(ctx, &store.FindTag{
|
||||||
|
CreatorID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||||
|
}
|
||||||
|
tagNameList := []string{}
|
||||||
|
for _, tag := range list {
|
||||||
|
tagNameList = append(tagNameList, tag.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagMapSet := make(map[string]bool)
|
||||||
|
for _, memo := range memoMessageList {
|
||||||
|
for _, tag := range findTagListFromMemoContent(memo.Content) {
|
||||||
|
if !slices.Contains(tagNameList, tag) {
|
||||||
|
tagMapSet[tag] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagList := []string{}
|
||||||
|
for tag := range tagMapSet {
|
||||||
|
tagList = append(tagList, tag)
|
||||||
|
}
|
||||||
|
sort.Strings(tagList)
|
||||||
|
return c.JSON(http.StatusOK, tagList)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.POST("/tag/delete", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagDelete := &DeleteTagRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(tagDelete); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if tagDelete.Name == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Store.DeleteTag(ctx, &store.DeleteTag{
|
||||||
|
Name: tagDelete.Name,
|
||||||
|
CreatorID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagDelete.Name)).SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createTagCreateActivity(c echo.Context, tag *Tag) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
payload := ActivityTagCreatePayload{
|
||||||
|
TagName: tag.Name,
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||||
|
CreatorID: tag.CreatorID,
|
||||||
|
Type: ActivityTagCreate.String(),
|
||||||
|
Level: ActivityInfo.String(),
|
||||||
|
Payload: string(payloadBytes),
|
||||||
|
})
|
||||||
|
if err != nil || activity == nil {
|
||||||
|
return errors.Wrap(err, "failed to create activity")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertTagFromStore(tag *store.Tag) *Tag {
|
||||||
|
return &Tag{
|
||||||
|
Name: tag.Name,
|
||||||
|
CreatorID: tag.CreatorID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagRegexp = regexp.MustCompile(`#([^\s#,]+)`)
|
||||||
|
|
||||||
|
func findTagListFromMemoContent(memoContent string) []string {
|
||||||
|
tagMapSet := make(map[string]bool)
|
||||||
|
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
|
||||||
|
for _, v := range matches {
|
||||||
|
tagName := v[1]
|
||||||
|
tagMapSet[tagName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tagList := []string{}
|
||||||
|
for tag := range tagMapSet {
|
||||||
|
tagList = append(tagList, tag)
|
||||||
|
}
|
||||||
|
sort.Strings(tagList)
|
||||||
|
return tagList
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindTagListFromMemoContent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
memoContent string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
memoContent: "#tag1 ",
|
||||||
|
want: []string{"tag1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memoContent: "#tag1 #tag2 ",
|
||||||
|
want: []string{"tag1", "tag2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memoContent: "#tag1 #tag2 \n#tag3 ",
|
||||||
|
want: []string{"tag1", "tag2", "tag3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
|
||||||
|
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memoContent: "#tag1 #tag2 \n#tag3 #tag4 ",
|
||||||
|
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ",
|
||||||
|
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
|
||||||
|
want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
result := findTagListFromMemoContent(test.memoContent)
|
||||||
|
if len(result) != len(test.want) {
|
||||||
|
t.Errorf("Find tag list %s: got result %v, want %v.", test.memoContent, result, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/usememos/memos/common/util"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role is the type of a role.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RoleHost is the HOST role.
|
||||||
|
RoleHost Role = "HOST"
|
||||||
|
// RoleAdmin is the ADMIN role.
|
||||||
|
RoleAdmin Role = "ADMIN"
|
||||||
|
// RoleUser is the USER role.
|
||||||
|
RoleUser Role = "USER"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (role Role) String() string {
|
||||||
|
return string(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
|
||||||
|
// Standard fields
|
||||||
|
RowStatus RowStatus `json:"rowStatus"`
|
||||||
|
CreatedTs int64 `json:"createdTs"`
|
||||||
|
UpdatedTs int64 `json:"updatedTs"`
|
||||||
|
|
||||||
|
// Domain specific fields
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
OpenID string `json:"openId"`
|
||||||
|
AvatarURL string `json:"avatarUrl"`
|
||||||
|
UserSettingList []*UserSetting `json:"userSettingList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (create CreateUserRequest) Validate() error {
|
||||||
|
if len(create.Username) < 3 {
|
||||||
|
return fmt.Errorf("username is too short, minimum length is 3")
|
||||||
|
}
|
||||||
|
if len(create.Username) > 32 {
|
||||||
|
return fmt.Errorf("username is too long, maximum length is 32")
|
||||||
|
}
|
||||||
|
if len(create.Password) < 3 {
|
||||||
|
return fmt.Errorf("password is too short, minimum length is 3")
|
||||||
|
}
|
||||||
|
if len(create.Password) > 512 {
|
||||||
|
return fmt.Errorf("password is too long, maximum length is 512")
|
||||||
|
}
|
||||||
|
if len(create.Nickname) > 64 {
|
||||||
|
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||||
|
}
|
||||||
|
if create.Email != "" {
|
||||||
|
if len(create.Email) > 256 {
|
||||||
|
return fmt.Errorf("email is too long, maximum length is 256")
|
||||||
|
}
|
||||||
|
if !util.ValidateEmail(create.Email) {
|
||||||
|
return fmt.Errorf("invalid email format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
|
Username *string `json:"username"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Nickname *string `json:"nickname"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
ResetOpenID *bool `json:"resetOpenId"`
|
||||||
|
AvatarURL *string `json:"avatarUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (update UpdateUserRequest) Validate() error {
|
||||||
|
if update.Username != nil && len(*update.Username) < 3 {
|
||||||
|
return fmt.Errorf("username is too short, minimum length is 3")
|
||||||
|
}
|
||||||
|
if update.Username != nil && len(*update.Username) > 32 {
|
||||||
|
return fmt.Errorf("username is too long, maximum length is 32")
|
||||||
|
}
|
||||||
|
if update.Password != nil && len(*update.Password) < 3 {
|
||||||
|
return fmt.Errorf("password is too short, minimum length is 3")
|
||||||
|
}
|
||||||
|
if update.Password != nil && len(*update.Password) > 512 {
|
||||||
|
return fmt.Errorf("password is too long, maximum length is 512")
|
||||||
|
}
|
||||||
|
if update.Nickname != nil && len(*update.Nickname) > 64 {
|
||||||
|
return fmt.Errorf("nickname is too long, maximum length is 64")
|
||||||
|
}
|
||||||
|
if update.AvatarURL != nil {
|
||||||
|
if len(*update.AvatarURL) > 2<<20 {
|
||||||
|
return fmt.Errorf("avatar is too large, maximum is 2MB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if update.Email != nil && *update.Email != "" {
|
||||||
|
if len(*update.Email) > 256 {
|
||||||
|
return fmt.Errorf("email is too long, maximum length is 256")
|
||||||
|
}
|
||||||
|
if !util.ValidateEmail(*update.Email) {
|
||||||
|
return fmt.Errorf("invalid email format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||||
|
// POST /user - Create a new user.
|
||||||
|
g.POST("/user", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
if currentUser.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
userCreate := &CreateUserRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := userCreate.Validate(); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||||
|
}
|
||||||
|
// Disallow host user to be created.
|
||||||
|
if userCreate.Role == RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "Could not create host user")
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.CreateUser(ctx, &store.User{
|
||||||
|
Username: userCreate.Username,
|
||||||
|
Role: store.Role(userCreate.Role),
|
||||||
|
Email: userCreate.Email,
|
||||||
|
Nickname: userCreate.Nickname,
|
||||||
|
PasswordHash: string(passwordHash),
|
||||||
|
OpenID: util.GenUUID(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
if err := s.createUserCreateActivity(c, userMessage); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /user - List all users.
|
||||||
|
g.GET("/user", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessageList := make([]*User, 0, len(list))
|
||||||
|
for _, user := range list {
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
// data desensitize
|
||||||
|
userMessage.OpenID = ""
|
||||||
|
userMessage.Email = ""
|
||||||
|
userMessageList = append(userMessageList, userMessage)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, userMessageList)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /user/me - Get current user.
|
||||||
|
g.GET("/user/me", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
|
||||||
|
UserID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
|
||||||
|
}
|
||||||
|
userSettingList := []*UserSetting{}
|
||||||
|
for _, userSetting := range list {
|
||||||
|
userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting))
|
||||||
|
}
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
userMessage.UserSettingList = userSettingList
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /user/:id - Get user by id.
|
||||||
|
g.GET("/user/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &id})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
// data desensitize
|
||||||
|
userMessage.OpenID = ""
|
||||||
|
userMessage.Email = ""
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /user/:id - Update user by id.
|
||||||
|
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ID: ¤tUserID})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||||
|
} else if currentUser.Role != store.RoleHost && currentUserID != userID {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to update user").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &UpdateUserRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(request); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := request.Validate(); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid update user request").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTs := time.Now().Unix()
|
||||||
|
userUpdate := &store.UpdateUser{
|
||||||
|
ID: userID,
|
||||||
|
UpdatedTs: ¤tTs,
|
||||||
|
}
|
||||||
|
if request.RowStatus != nil {
|
||||||
|
rowStatus := store.RowStatus(request.RowStatus.String())
|
||||||
|
userUpdate.RowStatus = &rowStatus
|
||||||
|
}
|
||||||
|
if request.Username != nil {
|
||||||
|
userUpdate.Username = request.Username
|
||||||
|
}
|
||||||
|
if request.Email != nil {
|
||||||
|
userUpdate.Email = request.Email
|
||||||
|
}
|
||||||
|
if request.Nickname != nil {
|
||||||
|
userUpdate.Nickname = request.Nickname
|
||||||
|
}
|
||||||
|
if request.Password != nil {
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHashStr := string(passwordHash)
|
||||||
|
userUpdate.PasswordHash = &passwordHashStr
|
||||||
|
}
|
||||||
|
if request.ResetOpenID != nil && *request.ResetOpenID {
|
||||||
|
openID := util.GenUUID()
|
||||||
|
userUpdate.OpenID = &openID
|
||||||
|
}
|
||||||
|
if request.AvatarURL != nil {
|
||||||
|
userUpdate.AvatarURL = request.AvatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.UpdateUser(ctx, userUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
|
||||||
|
UserID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
|
||||||
|
}
|
||||||
|
userSettingList := []*UserSetting{}
|
||||||
|
for _, userSetting := range list {
|
||||||
|
userSettingList = append(userSettingList, convertUserSettingFromStore(userSetting))
|
||||||
|
}
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
userMessage.UserSettingList = userSettingList
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /user/:id - Delete user by id.
|
||||||
|
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: ¤tUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||||
|
} else if currentUser.Role != store.RoleHost {
|
||||||
|
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete user").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userDelete := &store.DeleteUser{
|
||||||
|
ID: userID,
|
||||||
|
}
|
||||||
|
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createUserCreateActivity(c echo.Context, user *User) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
payload := ActivityUserCreatePayload{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||||||
|
CreatorID: user.ID,
|
||||||
|
Type: ActivityUserCreate.String(),
|
||||||
|
Level: ActivityInfo.String(),
|
||||||
|
Payload: string(payloadBytes),
|
||||||
|
})
|
||||||
|
if err != nil || activity == nil {
|
||||||
|
return errors.Wrap(err, "failed to create activity")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserFromStore(user *store.User) *User {
|
||||||
|
return &User{
|
||||||
|
ID: user.ID,
|
||||||
|
RowStatus: RowStatus(user.RowStatus),
|
||||||
|
CreatedTs: user.CreatedTs,
|
||||||
|
UpdatedTs: user.UpdatedTs,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: Role(user.Role),
|
||||||
|
Email: user.Email,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
PasswordHash: user.PasswordHash,
|
||||||
|
OpenID: user.OpenID,
|
||||||
|
AvatarURL: user.AvatarURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserSettingKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// UserSettingLocaleKey is the key type for user locale.
|
||||||
|
UserSettingLocaleKey UserSettingKey = "locale"
|
||||||
|
// UserSettingAppearanceKey is the key type for user appearance.
|
||||||
|
UserSettingAppearanceKey UserSettingKey = "appearance"
|
||||||
|
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
|
||||||
|
UserSettingMemoVisibilityKey UserSettingKey = "memo-visibility"
|
||||||
|
// UserSettingTelegramUserID is the key type for telegram UserID of memos user.
|
||||||
|
UserSettingTelegramUserIDKey UserSettingKey = "telegram-user-id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string format of UserSettingKey type.
|
||||||
|
func (key UserSettingKey) String() string {
|
||||||
|
switch key {
|
||||||
|
case UserSettingLocaleKey:
|
||||||
|
return "locale"
|
||||||
|
case UserSettingAppearanceKey:
|
||||||
|
return "appearance"
|
||||||
|
case UserSettingMemoVisibilityKey:
|
||||||
|
return "memo-visibility"
|
||||||
|
case UserSettingTelegramUserIDKey:
|
||||||
|
return "telegram-user-id"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
UserSettingLocaleValue = []string{
|
||||||
|
"de",
|
||||||
|
"en",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"hr",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"nl",
|
||||||
|
"pl",
|
||||||
|
"pt-BR",
|
||||||
|
"ru",
|
||||||
|
"sl",
|
||||||
|
"sv",
|
||||||
|
"tr",
|
||||||
|
"uk",
|
||||||
|
"vi",
|
||||||
|
"zh-Hans",
|
||||||
|
"zh-Hant",
|
||||||
|
}
|
||||||
|
UserSettingAppearanceValue = []string{"system", "light", "dark"}
|
||||||
|
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserSetting struct {
|
||||||
|
UserID int `json:"userId"`
|
||||||
|
Key UserSettingKey `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertUserSettingRequest struct {
|
||||||
|
UserID int `json:"-"`
|
||||||
|
Key UserSettingKey `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (upsert UpsertUserSettingRequest) Validate() error {
|
||||||
|
if upsert.Key == UserSettingLocaleKey {
|
||||||
|
localeValue := "en"
|
||||||
|
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal user setting locale value")
|
||||||
|
}
|
||||||
|
if !slices.Contains(UserSettingLocaleValue, localeValue) {
|
||||||
|
return fmt.Errorf("invalid user setting locale value")
|
||||||
|
}
|
||||||
|
} else if upsert.Key == UserSettingAppearanceKey {
|
||||||
|
appearanceValue := "system"
|
||||||
|
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal user setting appearance value")
|
||||||
|
}
|
||||||
|
if !slices.Contains(UserSettingAppearanceValue, appearanceValue) {
|
||||||
|
return fmt.Errorf("invalid user setting appearance value")
|
||||||
|
}
|
||||||
|
} else if upsert.Key == UserSettingMemoVisibilityKey {
|
||||||
|
memoVisibilityValue := Private
|
||||||
|
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
|
||||||
|
}
|
||||||
|
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
|
||||||
|
return fmt.Errorf("invalid user setting memo visibility value")
|
||||||
|
}
|
||||||
|
} else if upsert.Key == UserSettingTelegramUserIDKey {
|
||||||
|
var key string
|
||||||
|
err := json.Unmarshal([]byte(upsert.Value), &key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid user setting telegram user id value")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("invalid user setting key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerUserSettingRoutes(g *echo.Group) {
|
||||||
|
g.POST("/user/setting", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
|
||||||
|
userSettingUpsert := &UpsertUserSettingRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := userSettingUpsert.Validate(); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting format").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userSettingUpsert.UserID = userID
|
||||||
|
userSetting, err := s.Store.UpsertUserSetting(ctx, &store.UserSetting{
|
||||||
|
UserID: userID,
|
||||||
|
Key: userSettingUpsert.Key.String(),
|
||||||
|
Value: userSettingUpsert.Value,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userSettingMessage := convertUserSettingFromStore(userSetting)
|
||||||
|
return c.JSON(http.StatusOK, userSettingMessage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertUserSettingFromStore(userSetting *store.UserSetting) *UserSetting {
|
||||||
|
return &UserSetting{
|
||||||
|
UserID: userSetting.UserID,
|
||||||
|
Key: UserSettingKey(userSetting.Key),
|
||||||
|
Value: userSetting.Value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/usememos/memos/server/profile"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIV1Service struct {
|
||||||
|
Secret string
|
||||||
|
Profile *profile.Profile
|
||||||
|
Store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store) *APIV1Service {
|
||||||
|
return &APIV1Service{
|
||||||
|
Secret: secret,
|
||||||
|
Profile: profile,
|
||||||
|
Store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) Register(rootGroup *echo.Group) {
|
||||||
|
// Register RSS routes.
|
||||||
|
s.registerRSSRoutes(rootGroup)
|
||||||
|
|
||||||
|
// Register API v1 routes.
|
||||||
|
apiV1Group := rootGroup.Group("/api/v1")
|
||||||
|
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return JWTMiddleware(s, next, s.Secret)
|
||||||
|
})
|
||||||
|
s.registerSystemRoutes(apiV1Group)
|
||||||
|
s.registerSystemSettingRoutes(apiV1Group)
|
||||||
|
s.registerAuthRoutes(apiV1Group)
|
||||||
|
s.registerIdentityProviderRoutes(apiV1Group)
|
||||||
|
s.registerUserRoutes(apiV1Group)
|
||||||
|
s.registerUserSettingRoutes(apiV1Group)
|
||||||
|
s.registerTagRoutes(apiV1Group)
|
||||||
|
s.registerShortcutRoutes(apiV1Group)
|
||||||
|
s.registerStorageRoutes(apiV1Group)
|
||||||
|
s.registerResourceRoutes(apiV1Group)
|
||||||
|
s.registerMemoRoutes(apiV1Group)
|
||||||
|
s.registerMemoOrganizerRoutes(apiV1Group)
|
||||||
|
s.registerMemoResourceRoutes(apiV1Group)
|
||||||
|
s.registerMemoRelationRoutes(apiV1Group)
|
||||||
|
s.registerOpenAIRoutes(apiV1Group)
|
||||||
|
|
||||||
|
// Register public routes.
|
||||||
|
publicGroup := rootGroup.Group("/o")
|
||||||
|
publicGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return JWTMiddleware(s, next, s.Secret)
|
||||||
|
})
|
||||||
|
s.registerGetterPublicRoutes(publicGroup)
|
||||||
|
s.registerResourcePublicRoutes(publicGroup)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
|
"github.com/usememos/memos/server"
|
||||||
|
_profile "github.com/usememos/memos/server/profile"
|
||||||
|
"github.com/usememos/memos/setup"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
"github.com/usememos/memos/store/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
greetingBanner = `
|
||||||
|
███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗
|
||||||
|
████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝
|
||||||
|
██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗
|
||||||
|
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║
|
||||||
|
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║
|
||||||
|
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
profile *_profile.Profile
|
||||||
|
mode string
|
||||||
|
port int
|
||||||
|
data string
|
||||||
|
|
||||||
|
rootCmd = &cobra.Command{
|
||||||
|
Use: "memos",
|
||||||
|
Short: `An open-source, self-hosted memo hub with knowledge management and social networking.`,
|
||||||
|
Run: func(_cmd *cobra.Command, _args []string) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
db := db.NewDB(profile)
|
||||||
|
if err := db.Open(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
fmt.Printf("failed to open db, error: %+v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store := store.New(db.DBInstance, profile)
|
||||||
|
s, err := server.NewServer(ctx, profile, store)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
fmt.Printf("failed to create server, error: %+v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := make(chan os.Signal, 1)
|
||||||
|
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
||||||
|
// The default signal sent by the `kill` command is SIGTERM,
|
||||||
|
// which is taken as the graceful shutdown signal for many systems, eg., Kubernetes, Gunicorn.
|
||||||
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig := <-c
|
||||||
|
fmt.Printf("%s received.\n", sig.String())
|
||||||
|
s.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
println(greetingBanner)
|
||||||
|
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
|
||||||
|
if err := s.Start(ctx); err != nil {
|
||||||
|
if err != http.ErrServerClosed {
|
||||||
|
fmt.Printf("failed to start server, error: %+v\n", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for CTRL-C.
|
||||||
|
<-ctx.Done()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCmd = &cobra.Command{
|
||||||
|
Use: "setup",
|
||||||
|
Short: "Make initial setup for memos",
|
||||||
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
hostUsername, err := cmd.Flags().GetString(setupCmdFlagHostUsername)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to get owner username, error: %+v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hostPassword, err := cmd.Flags().GetString(setupCmdFlagHostPassword)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to get owner password, error: %+v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := db.NewDB(profile)
|
||||||
|
if err := db.Open(ctx); err != nil {
|
||||||
|
fmt.Printf("failed to open db, error: %+v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store := store.New(db.DBInstance, profile)
|
||||||
|
if err := setup.Execute(ctx, store, hostUsername, hostPassword); err != nil {
|
||||||
|
fmt.Printf("failed to setup, error: %+v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func Execute() error {
|
||||||
|
return rootCmd.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
||||||
|
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8081, "port of server")
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
||||||
|
|
||||||
|
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
viper.SetDefault("mode", "demo")
|
||||||
|
viper.SetDefault("port", 8081)
|
||||||
|
viper.SetEnvPrefix("memos")
|
||||||
|
|
||||||
|
setupCmd.Flags().String(setupCmdFlagHostUsername, "", "Owner username")
|
||||||
|
setupCmd.Flags().String(setupCmdFlagHostPassword, "", "Owner password")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(setupCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initConfig() {
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
var err error
|
||||||
|
profile, err = _profile.GetProfile()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to get profile, error: %+v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
println("---")
|
||||||
|
println("Server profile")
|
||||||
|
println("dsn:", profile.DSN)
|
||||||
|
println("port:", profile.Port)
|
||||||
|
println("mode:", profile.Mode)
|
||||||
|
println("version:", profile.Version)
|
||||||
|
println("---")
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
setupCmdFlagHostUsername = "host-username"
|
||||||
|
setupCmdFlagHostPassword = "host-password"
|
||||||
|
)
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"github.com/usememos/memos/internal/profile"
|
|
||||||
"github.com/usememos/memos/internal/version"
|
|
||||||
"github.com/usememos/memos/server"
|
|
||||||
"github.com/usememos/memos/store"
|
|
||||||
"github.com/usememos/memos/store/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
rootCmd = &cobra.Command{
|
|
||||||
Use: "memos",
|
|
||||||
Short: `An open source, lightweight note-taking service. Easily capture and share your great thoughts.`,
|
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
|
||||||
instanceProfile := &profile.Profile{
|
|
||||||
Mode: viper.GetString("mode"),
|
|
||||||
Addr: viper.GetString("addr"),
|
|
||||||
Port: viper.GetInt("port"),
|
|
||||||
UNIXSock: viper.GetString("unix-sock"),
|
|
||||||
Data: viper.GetString("data"),
|
|
||||||
Driver: viper.GetString("driver"),
|
|
||||||
DSN: viper.GetString("dsn"),
|
|
||||||
InstanceURL: viper.GetString("instance-url"),
|
|
||||||
Version: version.GetCurrentVersion(viper.GetString("mode")),
|
|
||||||
}
|
|
||||||
if err := instanceProfile.Validate(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
dbDriver, err := db.NewDBDriver(instanceProfile)
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
slog.Error("failed to create db driver", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
storeInstance := store.New(dbDriver, instanceProfile)
|
|
||||||
if err := storeInstance.Migrate(ctx); err != nil {
|
|
||||||
cancel()
|
|
||||||
slog.Error("failed to migrate", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := server.NewServer(ctx, instanceProfile, storeInstance)
|
|
||||||
if err != nil {
|
|
||||||
cancel()
|
|
||||||
slog.Error("failed to create server", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
|
||||||
// Trigger graceful shutdown on SIGINT or SIGTERM.
|
|
||||||
// The default signal sent by the `kill` command is SIGTERM,
|
|
||||||
// which is taken as the graceful shutdown signal for many systems, eg., Kubernetes, Gunicorn.
|
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
if err := s.Start(ctx); err != nil {
|
|
||||||
if err != http.ErrServerClosed {
|
|
||||||
slog.Error("failed to start server", "error", err)
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printGreetings(instanceProfile)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-c
|
|
||||||
s.Shutdown(ctx)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for CTRL-C.
|
|
||||||
<-ctx.Done()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
viper.SetDefault("mode", "dev")
|
|
||||||
viper.SetDefault("driver", "sqlite")
|
|
||||||
viper.SetDefault("port", 8081)
|
|
||||||
|
|
||||||
rootCmd.PersistentFlags().String("mode", "dev", `mode of server, can be "prod" or "dev" or "demo"`)
|
|
||||||
rootCmd.PersistentFlags().String("addr", "", "address of server")
|
|
||||||
rootCmd.PersistentFlags().Int("port", 8081, "port of server")
|
|
||||||
rootCmd.PersistentFlags().String("unix-sock", "", "path to the unix socket, overrides --addr and --port")
|
|
||||||
rootCmd.PersistentFlags().String("data", "", "data directory")
|
|
||||||
rootCmd.PersistentFlags().String("driver", "sqlite", "database driver")
|
|
||||||
rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)")
|
|
||||||
rootCmd.PersistentFlags().String("instance-url", "", "the url of your memos instance")
|
|
||||||
|
|
||||||
if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("addr", rootCmd.PersistentFlags().Lookup("addr")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("unix-sock", rootCmd.PersistentFlags().Lookup("unix-sock")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := viper.BindPFlag("instance-url", rootCmd.PersistentFlags().Lookup("instance-url")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
viper.SetEnvPrefix("memos")
|
|
||||||
viper.AutomaticEnv()
|
|
||||||
if err := viper.BindEnv("instance-url", "MEMOS_INSTANCE_URL"); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printGreetings(profile *profile.Profile) {
|
|
||||||
fmt.Printf("Memos %s started successfully!\n", profile.Version)
|
|
||||||
|
|
||||||
if profile.IsDev() {
|
|
||||||
fmt.Fprint(os.Stderr, "Development mode is enabled\n")
|
|
||||||
if profile.DSN != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Database: %s\n", profile.DSN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server information
|
|
||||||
fmt.Printf("Data directory: %s\n", profile.Data)
|
|
||||||
fmt.Printf("Database driver: %s\n", profile.Driver)
|
|
||||||
fmt.Printf("Mode: %s\n", profile.Mode)
|
|
||||||
|
|
||||||
// Connection information
|
|
||||||
if len(profile.UNIXSock) == 0 {
|
|
||||||
if len(profile.Addr) == 0 {
|
|
||||||
fmt.Printf("Server running on port %d\n", profile.Port)
|
|
||||||
fmt.Printf("Access your memos at: http://localhost:%d\n", profile.Port)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Server running on %s:%d\n", profile.Addr, profile.Port)
|
|
||||||
fmt.Printf("Access your memos at: http://%s:%d\n", profile.Addr, profile.Port)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Server running on unix socket: %s\n", profile.UNIXSock)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("Documentation: %s\n", "https://usememos.com")
|
|
||||||
fmt.Printf("Source code: %s\n", "https://github.com/usememos/memos")
|
|
||||||
fmt.Println("\nHappy note-taking!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Package log implements a simple logging package.
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// `gl` is the global logger.
|
||||||
|
// Other packages should use public methods such as Info/Error to do the logging.
|
||||||
|
// For other types of logging, e.g. logging to a separate file, they should use their own loggers.
|
||||||
|
gl *zap.Logger
|
||||||
|
gLevel zap.AtomicLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initializes the global console logger.
|
||||||
|
func init() {
|
||||||
|
gLevel = zap.NewAtomicLevelAt(zap.InfoLevel)
|
||||||
|
gl, _ = zap.Config{
|
||||||
|
Level: gLevel,
|
||||||
|
Development: true,
|
||||||
|
// Use "console" to print readable stacktrace.
|
||||||
|
Encoding: "console",
|
||||||
|
EncoderConfig: zap.NewDevelopmentEncoderConfig(),
|
||||||
|
OutputPaths: []string{"stderr"},
|
||||||
|
ErrorOutputPaths: []string{"stderr"},
|
||||||
|
}.Build(
|
||||||
|
// Skip one caller stack to locate the correct caller.
|
||||||
|
zap.AddCallerSkip(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel wraps the zap Level's SetLevel method.
|
||||||
|
func SetLevel(level zapcore.Level) {
|
||||||
|
gLevel.SetLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnabledLevel wraps the zap Level's Enabled method.
|
||||||
|
func EnabledLevel(level zapcore.Level) bool {
|
||||||
|
return gLevel.Enabled(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug wraps the zap Logger's Debug method.
|
||||||
|
func Debug(msg string, fields ...zap.Field) {
|
||||||
|
gl.Debug(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info wraps the zap Logger's Info method.
|
||||||
|
func Info(msg string, fields ...zap.Field) {
|
||||||
|
gl.Info(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn wraps the zap Logger's Warn method.
|
||||||
|
func Warn(msg string, fields ...zap.Field) {
|
||||||
|
gl.Warn(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error wraps the zap Logger's Error method.
|
||||||
|
func Error(msg string, fields ...zap.Field) {
|
||||||
|
gl.Error(msg, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync wraps the zap Logger's Sync method.
|
||||||
|
func Sync() {
|
||||||
|
_ = gl.Sync()
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,14 @@
|
||||||
package util //nolint:revive // util namespace is intentional for shared helpers
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConvertStringToInt32 converts a string to int32.
|
|
||||||
func ConvertStringToInt32(src string) (int32, error) {
|
|
||||||
parsed, err := strconv.ParseInt(src, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return int32(parsed), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasPrefixes returns true if the string s has any of the given prefixes.
|
// HasPrefixes returns true if the string s has any of the given prefixes.
|
||||||
func HasPrefixes(src string, prefixes ...string) bool {
|
func HasPrefixes(src string, prefixes ...string) bool {
|
||||||
for _, prefix := range prefixes {
|
for _, prefix := range prefixes {
|
||||||
|
|
@ -41,6 +31,13 @@ func GenUUID() string {
|
||||||
return uuid.New().String()
|
return uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Min(x, y int) int {
|
||||||
|
if x < y {
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
|
||||||
// RandomString returns a random string with length n.
|
// RandomString returns a random string with length n.
|
||||||
|
|
@ -61,13 +58,3 @@ func RandomString(n int) (string, error) {
|
||||||
}
|
}
|
||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceString replaces all occurrences of old in slice with new.
|
|
||||||
func ReplaceString(slice []string, old, new string) []string {
|
|
||||||
for i, s := range slice {
|
|
||||||
if s == old {
|
|
||||||
slice[i] = new
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slice
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package util //nolint:revive // util is an appropriate package name for utility functions
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -14,7 +14,7 @@ func TestValidateEmail(t *testing.T) {
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: "@usememos.com",
|
email: "@qq.com",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 1.Prepare your workspace by:
|
||||||
|
# docker compose run api go install github.com/cosmtrek/air@latest
|
||||||
|
# docker compose run web npm install
|
||||||
|
#
|
||||||
|
# 2. Start you work by:
|
||||||
|
# docker compose up -d
|
||||||
|
#
|
||||||
|
# 3. Check logs by:
|
||||||
|
# docker compose logs -f
|
||||||
|
#
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: golang:1.19.3-alpine3.16
|
||||||
|
working_dir: /work
|
||||||
|
command: air -c ./scripts/.air.toml
|
||||||
|
volumes:
|
||||||
|
- $HOME/go/pkg/:/go/pkg/ # Cache for go mod shared with the host
|
||||||
|
- ./.air/bin/:/go/bin/ # Cache for binary used only in container, such as *air*
|
||||||
|
- .:/work/
|
||||||
|
web:
|
||||||
|
image: node:18.12.1-alpine3.16
|
||||||
|
working_dir: /work
|
||||||
|
depends_on: ["api"]
|
||||||
|
ports: ["3001:3001"]
|
||||||
|
environment: ["DEV_PROXY_SERVER=http://api:8081/"]
|
||||||
|
command: npm run dev
|
||||||
|
volumes:
|
||||||
|
- ./web:/work
|
||||||
|
- ./.air/node_modules/:/work/node_modules/ # Cache for Node Modules
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
version: "3.0"
|
||||||
|
|
||||||
|
# uffizzi integration
|
||||||
|
x-uffizzi:
|
||||||
|
ingress:
|
||||||
|
service: memos
|
||||||
|
port: 5230
|
||||||
|
|
||||||
|
services:
|
||||||
|
memos:
|
||||||
|
image: "${MEMOS_IMAGE}"
|
||||||
|
volumes:
|
||||||
|
- memos_volume:/var/opt/memos
|
||||||
|
command: ["--mode", "demo"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
memos_volume:
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
version: "3.0"
|
||||||
services:
|
services:
|
||||||
memos:
|
memos:
|
||||||
image: neosmemo/memos:latest
|
image: neosmemo/memos:latest
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Authentication APIs
|
||||||
|
|
||||||
|
## Sign In
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/signin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "john",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"username": "john",
|
||||||
|
"nickname": "John"
|
||||||
|
// other user fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Sign in success
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Incorrect credentials
|
||||||
|
- 403: User banned
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## SSO Sign In
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/signin/sso
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identityProviderId": 123,
|
||||||
|
"code": "abc123",
|
||||||
|
"redirectUri": "https://example.com/callback"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
Same as **Sign In**
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Authentication failed
|
||||||
|
- 403: User banned
|
||||||
|
- 404: Identity provider not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Sign Up
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/signup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "mary",
|
||||||
|
"password": "password456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
Same as **Sign In**
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Sign up success
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Sign up disabled
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Sign Out
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/signout
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```
|
||||||
|
true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 500: Internal server error
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Guide to Access Memos API with OpenID
|
||||||
|
|
||||||
|
Memos API supports using OpenID as the user identifier to access the API.
|
||||||
|
|
||||||
|
## What is OpenID
|
||||||
|
|
||||||
|
OpenID is a unique identifier assigned by Memos system to each user.
|
||||||
|
|
||||||
|
When a user registers or logs in via third-party OAuth through Memos system, the OpenID will be generated automatically.
|
||||||
|
|
||||||
|
## How to Get User's OpenID
|
||||||
|
|
||||||
|
You can get a user's OpenID through:
|
||||||
|
|
||||||
|
- User checks the personal profile page in Memos system
|
||||||
|
- Calling Memos API to get user details
|
||||||
|
- Retrieving from login API response after successful login
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
// GET /api/v1/user/me
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"username": "john",
|
||||||
|
"openId": "8613E04B4FA6603883F05A5E0A5E2517",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to Use OpenID to Access API
|
||||||
|
|
||||||
|
You can access the API on behalf of the user by appending `?openId=xxx` parameter to the API URL.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl 'https://demo.usememos.com/api/v1/memo?openId=8613E04B4FA6603883F05A5E0A5E2517' -H 'Content-Type: application/json' --data-raw '{"content":"Hello world!"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The above request will create a Memo under the user with OpenID `8613E04B4FA6603883F05A5E0A5E2517`.
|
||||||
|
|
||||||
|
OpenID can be used in any API that requires user identity.
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Memo Relation APIs
|
||||||
|
|
||||||
|
## Create Memo Relation
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/memo/:memoId/relation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"relatedMemoId": 456,
|
||||||
|
"type": "REFERENCE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"memoId": 123,
|
||||||
|
"relatedMemoId": 456,
|
||||||
|
"type": "REFERENCE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 400: Invalid request
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get Memo Relations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/memo/:memoId/relation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"memoId": 123,
|
||||||
|
"relatedMemoId": 456,
|
||||||
|
"type": "REFERENCE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Delete Memo Relation
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/memo/:memoId/relation/:relatedMemoId/type/:relationType
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Deleted
|
||||||
|
- 400: Invalid request
|
||||||
|
- 500: Internal server error
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Memo Resource APIs
|
||||||
|
|
||||||
|
## Bind Resource to Memo
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/memo/:memoId/resource
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resourceId": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```
|
||||||
|
true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 404: Memo/Resource not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get Memo Resources
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/memo/:memoId/resource
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"filename": "example.png"
|
||||||
|
// other resource fields
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Unbind Resource from Memo
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/memo/:memoId/resource/:resourceId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 404: Memo/Resource not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
# Memo APIs
|
||||||
|
|
||||||
|
## Create Memo
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/memo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "Memo content",
|
||||||
|
"visibility": "PUBLIC",
|
||||||
|
"resourceIdList": [123, 456],
|
||||||
|
"relationList": [{ "relatedMemoId": 789, "type": "REFERENCE" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1234,
|
||||||
|
"content": "Memo content",
|
||||||
|
"visibility": "PUBLIC"
|
||||||
|
// other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Created
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Forbidden to create public memo
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get Memo List
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/memo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- `creatorId` (optional): Filter by creator ID
|
||||||
|
- `visibility` (optional): Filter visibility, `PUBLIC`, `PROTECTED` or `PRIVATE`
|
||||||
|
- `pinned` (optional): Filter pinned memo, `true` or `false`
|
||||||
|
- `tag` (optional): Filter memo with tag
|
||||||
|
- `content` (optional): Search in content
|
||||||
|
- `limit` (optional): Limit number of results
|
||||||
|
- `offset` (optional): Offset of first result
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1234,
|
||||||
|
"content": "Memo 1"
|
||||||
|
// other fields
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5678,
|
||||||
|
"content": "Memo 2"
|
||||||
|
// other fields
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Get Memo By ID
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/memo/:memoId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1234,
|
||||||
|
"content": "Memo content"
|
||||||
|
// other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 403: Forbidden for private memo
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Update Memo
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/memo/:memoId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "Updated content",
|
||||||
|
"visibility": "PRIVATE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
Same as **Get Memo By ID**
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Updated
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Forbidden
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Delete Memo
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/memo/:memoId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Deleted
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Forbidden
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Resource APIs
|
||||||
|
|
||||||
|
## Upload Resource
|
||||||
|
|
||||||
|
### Upload File
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/resource/blob
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Form**
|
||||||
|
|
||||||
|
- `file`: Upload file
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"filename": "example.png"
|
||||||
|
// other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 413: File too large
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
### Create Resource
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/resource
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "example.png",
|
||||||
|
"externalLink": "https://example.com/image.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
Same as **Upload File**
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get Resource List
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/resource
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- `limit` (optional): Limit number of results
|
||||||
|
- `offset` (optional): Offset of first result
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"filename": "example.png"
|
||||||
|
// other fields
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 456,
|
||||||
|
"filename": "doc.pdf"
|
||||||
|
// other fields
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Update Resource
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/resource/:resourceId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "new_name.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
Same as **Get Resource List**
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Delete Resource
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/resource/:resourceId
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Deleted
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Tag APIs
|
||||||
|
|
||||||
|
## Create Tag
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/tag
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "python"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```
|
||||||
|
"python"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Created
|
||||||
|
- 400: Invalid request
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get Tag List
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tag
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
["python", "golang", "javascript"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Suggest Tags
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/tag/suggestion
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
["django", "flask", "numpy"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: OK
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Delete Tag
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/tag/delete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "outdated_tag"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Deleted
|
||||||
|
- 400: Invalid request
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 500: Internal server error
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
# User APIs
|
||||||
|
|
||||||
|
## Create User
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "john",
|
||||||
|
"role": "USER",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"nickname": "John",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"username": "john",
|
||||||
|
"role": "USER",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"nickname": "John",
|
||||||
|
"avatarUrl": "",
|
||||||
|
"createdTs": 1596647800,
|
||||||
|
"updatedTs": 1596647800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 400: Validation error
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 403: Forbidden to create host user
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get User List
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"username": "john",
|
||||||
|
"role": "USER"
|
||||||
|
// other fields
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 456,
|
||||||
|
"username": "mary",
|
||||||
|
"role": "ADMIN"
|
||||||
|
// other fields
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get User By ID
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/user/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"username": "john",
|
||||||
|
"role": "USER"
|
||||||
|
// other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Update User
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/user/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "johnny",
|
||||||
|
"email": "johnny@example.com",
|
||||||
|
"nickname": "Johnny",
|
||||||
|
"avatarUrl": "https://avatars.example.com/u=123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"username": "johnny",
|
||||||
|
"role": "USER",
|
||||||
|
"email": "johnny@example.com",
|
||||||
|
"nickname": "Johnny",
|
||||||
|
"avatarUrl": "https://avatars.example.com/u=123",
|
||||||
|
"createdTs": 1596647800,
|
||||||
|
"updatedTs": 1596647900
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 400: Validation error
|
||||||
|
- 403: Forbidden
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Delete User
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/v1/user/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 403: Forbidden
|
||||||
|
- 404: Not found
|
||||||
|
- 500: Internal server error
|
||||||
|
|
||||||
|
## Get Current User
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/user/me
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**
|
||||||
|
|
||||||
|
Same as **Get User By ID**
|
||||||
|
|
||||||
|
**Status Codes**
|
||||||
|
|
||||||
|
- 200: Success
|
||||||
|
- 401: Unauthorized
|
||||||
|
- 500: Internal server error
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Adding A Custom Theme
|
||||||
|
|
||||||
|
1. Open the Settings Dialog
|
||||||
|
2. Navigate to the System Tab
|
||||||
|
3. In the "Additional Styles" box add these lines of code:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.memo-list-container {
|
||||||
|
background-color: #INSERT COLOR HERE;
|
||||||
|
}
|
||||||
|
.page-container {
|
||||||
|
background-color: #INSERT COLOR HERE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is recommended that you choose the same color for both options
|
||||||
|
|
||||||
|
4. Refresh the page and the background color of your memos app will successfully update to reflect your changes
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
# A Beginner's Guide to Deploying Memos on Render.com
|
||||||
|
|
||||||
|
written by [AJ](https://memos.ajstephens.website/) (also a noob)
|
||||||
|
|
||||||
|
<img height="64px" src="https://usememos.com/logo-full.png" alt="✍️ memos" />
|
||||||
|
|
||||||
|
[Live Demo](https://demo.usememos.com) • [Official Website](https://usememos.com) • [Source Code](https://github.com/usememos/memos)
|
||||||
|
|
||||||
|
## Who is this guide for?
|
||||||
|
|
||||||
|
Someone who...
|
||||||
|
|
||||||
|
- doesn't have much experience with self hosting
|
||||||
|
- has a minimal understanding of docker
|
||||||
|
|
||||||
|
Someone who wants...
|
||||||
|
|
||||||
|
- to use memos
|
||||||
|
- to support the memos project
|
||||||
|
- a cost effective and simple way to host it on the cloud with reliability and persistance
|
||||||
|
- to share memos with friends
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Can follow instructions
|
||||||
|
- Have 7ish USD a month on a debit/credit card
|
||||||
|
|
||||||
|
## Guide
|
||||||
|
|
||||||
|
Create an account at [Render](https://dashboard.render.com/register)
|
||||||
|

|
||||||
|
|
||||||
|
1. Go to your dashboard
|
||||||
|
|
||||||
|
[https://dashboard.render.com/](https://dashboard.render.com/)
|
||||||
|
|
||||||
|
2. Select New Web Service
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. Scroll down to "Public Git repository"
|
||||||
|
|
||||||
|
4. Paste in the link for the public git repository for memos (https://github.com/usememos/memos) and press continue
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
5. Render will pre-fill most of the fields but you will need to create a unique name for your web service
|
||||||
|
|
||||||
|
6. Adjust region if you want to
|
||||||
|
|
||||||
|
7. Don't touch the "branch", "root directory", and "environment" fields
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
8. Click "enter your payment information" and do so
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
9. Select the starter plan ($7 a month - a requirement for persistant data - render's free instances spin down when inactive and lose all data)
|
||||||
|
|
||||||
|
10. Click "Create Web Service"
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
11. Wait patiently while the _magic_ happens 🤷♂️
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
12. After some time (~ 6 min for me) the build will finish and you will see the web service is live
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
13. Now it's time to add the disk so your data won't dissappear when the webservice redeploys (redeploys happen automatically when the public repo is updated)
|
||||||
|
|
||||||
|
14. Select the "Disks" tab on the left menu and then click "Add Disk"
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
15. Name your disk (can be whatever)
|
||||||
|
|
||||||
|
16. Set the "Mount Path" to `/var/opt/memos`
|
||||||
|
|
||||||
|
17. Set the disk size (default is 10GB but 1GB is plenty and can be increased at any time)
|
||||||
|
|
||||||
|
18. Click "Save"
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
19. Wait...again...while the webservice redeploys with the persistant disk
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
20. aaaand....we're back online!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
21. Time to test! We're going to make sure everything is working correctly.
|
||||||
|
|
||||||
|
22. Click the link in the top left, it should look like `https://the-name-you-chose.onrender.com` - this is your self hosted memos link!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
23. Create a Username and Password (remember these) then click "Sign up as Host"
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
24. Create a test memo then click save
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
25. Sign out of your self-hosted memos
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
26. Return to your Render dashboard, click the "Manual Deploy" dropdown button and click "Deploy latest commit" and wait until the webservice is live again (This is to test that your data is persistant)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
27. Once the webservice is live go back to your self-hosted memos page and sign in! (If your memos screen looks different then something went wrong)
|
||||||
|
|
||||||
|
28. Once you're logged in, verify your test memo is still there after the redeploy
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 🎉Celebrate!🎉
|
||||||
|
|
||||||
|
You did it! Enjoy using memos!
|
||||||
|
|
||||||
|
Want to learn more or need more guidance? Join the community on [telegram](https://t.me/+-_tNF1k70UU4ZTc9) and [discord](https://discord.gg/tfPJa4UmAv).
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Development
|
||||||
|
|
||||||
|
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
|
||||||
|
|
||||||
|
1. It has no external dependency.
|
||||||
|
2. It requires zero config.
|
||||||
|
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Frontend | Backend |
|
||||||
|
| ---------------------------------------- | --------------------------------- |
|
||||||
|
| [React](https://react.dev/) | [Go](https://go.dev/) |
|
||||||
|
| [Tailwind CSS](https://tailwindcss.com/) | [SQLite](https://www.sqlite.org/) |
|
||||||
|
| [Vite](https://vitejs.dev/) | |
|
||||||
|
| [pnpm](https://pnpm.io/) | |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Go](https://golang.org/doc/install)
|
||||||
|
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
|
||||||
|
- [Node.js](https://nodejs.org/)
|
||||||
|
- [pnpm](https://pnpm.io/installation)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
(Using PowerShell)
|
||||||
|
|
||||||
|
1. pull source code
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/usememos/memos
|
||||||
|
# or
|
||||||
|
gh repo clone usememos/memos
|
||||||
|
```
|
||||||
|
|
||||||
|
2. cd into the project root directory
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd memos
|
||||||
|
```
|
||||||
|
|
||||||
|
3. start backend using air (with live reload)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
air -c .\scripts\.air-windows.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
4. start frontend dev server
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd web; pnpm i; pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Memos should now be running at [http://localhost:3001](http://localhost:3001) and changing either frontend or backend code would trigger live reload.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Frontend must be built before backend. The built frontend must be placed in the backend ./server/dist directory. Otherwise, you will get a "No frontend embeded" error.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Move-Item "./server/dist" "./server/dist.bak"
|
||||||
|
cd web; pnpm i --frozen-lockfile; pnpm build; cd ..;
|
||||||
|
Move-Item "./web/dist" "./server/" -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go build -o ./build/memos.exe ./main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## ❕ Notes
|
||||||
|
|
||||||
|
- Start development servers easier by running the provided `start.ps1` script.
|
||||||
|
This will start both backend and frontend in detached PowerShell windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\start.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- Produce a local build easier using the provided `build.ps1` script to build both frontend and backend:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This will produce a memos.exe file in the ./build directory.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Development
|
||||||
|
|
||||||
|
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
|
||||||
|
|
||||||
|
1. It has no external dependency.
|
||||||
|
2. It requires zero config.
|
||||||
|
3. 1 command to start backend and 1 command to start frontend, both with live reload support.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Go](https://golang.org/doc/install)
|
||||||
|
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
|
||||||
|
- [Node.js](https://nodejs.org/)
|
||||||
|
- [pnpm](https://pnpm.io/installation)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. pull source code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/usememos/memos
|
||||||
|
```
|
||||||
|
|
||||||
|
2. start backend using air(with live reload)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
air -c scripts/.air.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
3. start frontend dev server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && pnpm i && pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Memos should now be running at [http://localhost:3001](http://localhost:3001) and change either frontend or backend code would trigger live reload.
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Setup
|
||||||
|
|
||||||
|
After deploying and running Memos in `prod` mode, you should create "host" user. There are two ways to do this:
|
||||||
|
|
||||||
|
1. Navigate to the Memos application URL, such as `http://localhost:5230`, and follow the prompts to create a username and password for the "host" user.
|
||||||
|
2. Use the command `memos setup --host-username=$USERNAME --host-password=$PASSWORD --mode=prod` to set up the host user. This method may be more convenient for deploying through Ansible or other provisioning softwares.
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Updating memos after deploying
|
||||||
|
|
||||||
|
## fly.io
|
||||||
|
|
||||||
|
### update to latest
|
||||||
|
Under the directory where you had your `fly.toml` file
|
||||||
|
|
||||||
|
```
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Installing memos as a service on Windows
|
||||||
|
|
||||||
|
While memos first-class support is for Docker, you may also install memos as a Windows service. It will run under SYSTEM account and start automatically at system boot.
|
||||||
|
|
||||||
|
❗ All service management methods requires admin privileges. Use [gsudo](https://gerardog.github.io/gsudo/docs/install), or open a new PowerShell terminal as admin:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Start-Process powershell -Verb RunAs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Choose one of the following methods
|
||||||
|
|
||||||
|
### 1. Using [NSSM](https://nssm.cc/download)
|
||||||
|
|
||||||
|
NSSM is a lightweight service wrapper.
|
||||||
|
|
||||||
|
You may put `nssm.exe` in the same directory as `memos.exe`, or add its directory to your system PATH. Prefer the latest 64-bit version of `nssm.exe`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Install memos as a service
|
||||||
|
nssm install memos "C:\path\to\memos.exe" --mode prod --port 5230
|
||||||
|
|
||||||
|
# Delay auto start
|
||||||
|
nssm set memos DisplayName "memos service"
|
||||||
|
|
||||||
|
# Configure extra service parameters
|
||||||
|
nssm set memos Description "A lightweight, self-hosted memo hub. https://usememos.com/"
|
||||||
|
|
||||||
|
# Delay auto start
|
||||||
|
nssm set memos Start SERVICE_DELAYED_AUTO_START
|
||||||
|
|
||||||
|
# Edit service using NSSM GUI
|
||||||
|
nssm edit memos
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
nssm start memos
|
||||||
|
|
||||||
|
# Remove the service, if ever needed
|
||||||
|
nssm remove memos confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Using [WinSW](https://github.com/winsw/winsw)
|
||||||
|
|
||||||
|
Find the latest release tag and download the asset `WinSW-net46x.exe`. Then, put it in the same directory as `memos.exe` and rename it to `memos-service.exe`.
|
||||||
|
|
||||||
|
Now, in the same directory, create the service configuration file `memos-service.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<service>
|
||||||
|
<id>memos</id>
|
||||||
|
<name>memos service</name>
|
||||||
|
<description>A lightweight, self-hosted memo hub. https://usememos.com/</description>
|
||||||
|
<onfailure action="restart" delay="10 sec"/>
|
||||||
|
<executable>%BASE%\memos.exe</executable>
|
||||||
|
<arguments>--mode prod --port 5230</arguments>
|
||||||
|
<delayedAutoStart>true</delayedAutoStart>
|
||||||
|
<log mode="none" />
|
||||||
|
</service>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, install the service:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Install the service
|
||||||
|
.\memos-service.exe install
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
.\memos-service.exe start
|
||||||
|
|
||||||
|
# Remove the service, if ever needed
|
||||||
|
.\memos-service.exe uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manage the service
|
||||||
|
|
||||||
|
You may use the `net` command to manage the service:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
net start memos
|
||||||
|
net stop memos
|
||||||
|
```
|
||||||
|
|
||||||
|
Also, by using one of the provided methods, the service will appear in the Windows Services Manager `services.msc`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- On Windows, memos store its data in the following directory:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ProgramData\memos
|
||||||
|
# Typically, this will resolve to C:\ProgramData\memos
|
||||||
|
```
|
||||||
|
|
||||||
|
You may specify a custom directory by appending `--data <path>` to the service command line.
|
||||||
|
|
||||||
|
- If the service fails to start, you should inspect the Windows Event Viewer `eventvwr.msc`.
|
||||||
|
|
||||||
|
- Memos will be accessible at [http://localhost:5230](http://localhost:5230) by default.
|
||||||
174
go.mod
174
go.mod
|
|
@ -1,94 +1,96 @@
|
||||||
module github.com/usememos/memos
|
module github.com/usememos/memos
|
||||||
|
|
||||||
go 1.25
|
go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.19.1
|
github.com/CorrectRoadH/echo-sse v0.1.4
|
||||||
github.com/aws/aws-sdk-go-v2 v1.39.2
|
github.com/PullRequestInc/go-gpt3 v1.1.15
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.31.12
|
github.com/aws/aws-sdk-go-v2 v1.17.4
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
|
github.com/aws/aws-sdk-go-v2/config v1.18.12
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4
|
github.com/aws/aws-sdk-go-v2/credentials v1.13.12
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
|
||||||
github.com/google/cel-go v0.26.1
|
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/gorilla/feeds v1.2.0
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2
|
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
|
||||||
github.com/lib/pq v1.10.9
|
|
||||||
github.com/lithammer/shortuuid/v4 v4.2.0
|
|
||||||
github.com/pkg/errors v0.9.1
|
|
||||||
github.com/spf13/cobra v1.10.1
|
|
||||||
github.com/spf13/viper v1.20.1
|
|
||||||
github.com/stretchr/testify v1.10.0
|
|
||||||
github.com/yuin/goldmark v1.7.13
|
|
||||||
golang.org/x/crypto v0.42.0
|
|
||||||
golang.org/x/mod v0.28.0
|
|
||||||
golang.org/x/net v0.43.0
|
|
||||||
golang.org/x/oauth2 v0.30.0
|
|
||||||
golang.org/x/sync v0.17.0
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1
|
|
||||||
google.golang.org/grpc v1.75.1
|
|
||||||
modernc.org/sqlite v1.38.2
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
cel.dev/expr v0.24.0 // indirect
|
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
|
||||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
|
||||||
github.com/spf13/afero v1.12.0 // indirect
|
|
||||||
github.com/spf13/cast v1.7.1 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
|
||||||
github.com/stoewer/go-strcase v1.3.1 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
|
|
||||||
golang.org/x/image v0.30.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect
|
|
||||||
modernc.org/libc v1.66.8 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect
|
|
||||||
github.com/aws/smithy-go v1.23.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/gorilla/feeds v1.1.1
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/labstack/echo/v4 v4.9.0
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/spf13/cobra v1.6.1
|
||||||
|
github.com/spf13/viper v1.15.0
|
||||||
|
github.com/stretchr/testify v1.8.1
|
||||||
|
github.com/yuin/goldmark v1.5.4
|
||||||
|
go.uber.org/zap v1.24.0
|
||||||
|
golang.org/x/crypto v0.1.0
|
||||||
|
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a
|
||||||
|
golang.org/x/mod v0.8.0
|
||||||
|
golang.org/x/net v0.7.0
|
||||||
|
golang.org/x/oauth2 v0.5.0
|
||||||
|
modernc.org/sqlite v1.24.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/image v0.7.0 // indirect
|
||||||
|
golang.org/x/tools v0.6.0 // indirect
|
||||||
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
|
modernc.org/cc/v3 v3.40.0 // indirect
|
||||||
|
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/opt v0.1.3 // indirect
|
||||||
|
modernc.org/strutil v1.1.3 // indirect
|
||||||
|
modernc.org/token v1.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.22 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.12.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.13.5 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||||
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||||
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/labstack/gommon v0.3.1 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/spf13/afero v1.9.3 // indirect
|
||||||
|
github.com/spf13/cast v1.5.0 // indirect
|
||||||
|
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/subosito/gotenv v1.4.2 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
go.uber.org/multierr v1.8.0 // indirect
|
||||||
golang.org/x/time v0.12.0 // indirect
|
golang.org/x/sys v0.5.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9
|
golang.org/x/text v0.9.0 // indirect
|
||||||
|
golang.org/x/time v0.1.0 // indirect
|
||||||
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
838
go.sum
838
go.sum
|
|
@ -1,233 +1,675 @@
|
||||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
|
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
|
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
|
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8=
|
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8=
|
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI=
|
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I=
|
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y=
|
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ=
|
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4 h1:BTl+TXrpnrpPWb/J3527GsJ/lMkn7z3GO12j6OlsbRg=
|
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4/go.mod h1:cG2tenc/fscpChiZE29a2crG9uo2t6nQGflFllFL8M8=
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970=
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA=
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU=
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI=
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
|
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8=
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I=
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
|
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY=
|
github.com/CorrectRoadH/echo-sse v0.1.4 h1:/g9vxJJasMTLFyeUT2q/TpGCgRvJuU9zx7laqPWppnY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo=
|
github.com/CorrectRoadH/echo-sse v0.1.4/go.mod h1:DRfO0yNv0gJLBFRysKKP7zfDmKfMuknakXBsTOVZUBI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA=
|
github.com/PullRequestInc/go-gpt3 v1.1.15 h1:pidXZbpqZVW0bp8NBNKDb+/++6PFdYfht9vw2CVpaUs=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
|
github.com/PullRequestInc/go-gpt3 v1.1.15/go.mod h1:F9yzAy070LhkqHS2154/IH0HVj5xq5g83gLTj7xzyfw=
|
||||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
github.com/aws/aws-sdk-go-v2 v1.17.4 h1:wyC6p9Yfq6V2y98wfDsj6OnNQa4w2BLGCLIxzNhwOGY=
|
||||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
github.com/aws/aws-sdk-go-v2 v1.17.4/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.18.12 h1:fKs/I4wccmfrNRO9rdrbMO1NgLxct6H9rNMiPdBxHWw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.18.12/go.mod h1:J36fOhj1LQBr+O4hJCiT8FwVvieeoSGOtPuvhKlsNu8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.13.12 h1:Cb+HhuEnV19zHRaYYVglwvdHGMJWbdsyP4oHhw04xws=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.13.12/go.mod h1:37HG2MBroXK3jXfxVGtbM2J48ra2+Ltu+tmwr/jO0KA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22 h1:3aMfcTmoXtTZnaT86QlVaYh+BRMbvrrmZwIQ5jWqCZQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22/go.mod h1:YGSIJyQ6D6FjKMQh16hVFSIUD54L4F7zTGePqYMYYJU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51 h1:iTFYCAdKzSAjGnVIUe88Hxvix0uaBqr0Rv7qJEOX5hE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51/go.mod h1:7Grl2gV+dx9SWrUIgwwlUvU40t7+lOSbx34XwfmsTkY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 h1:r+XwaCLpIvCKjBIYy/HVZujQS9tsz5ohHG3ZIe0wKoE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28/go.mod h1:3lwChorpIM/BhImY/hy+Z6jekmN92cXGPI1QJasVPYY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22 h1:7AwGYXDdqRQYsluvKFmWoqpcOQJ4bH634SkYf3FNj/A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22/go.mod h1:EqK7gVrIGAHyZItrD1D8B0ilgwMD1GiWAmbU4u/JHNk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29 h1:J4xhFd6zHhdF9jPP0FQJ6WknzBboGMBNjKOv4iTuw4A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29/go.mod h1:TwuqRBGzxjQJIwH16/fOZodwXt2Zxa9/cwJC5ke4j7s=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.19/go.mod h1:8W88sW3PjamQpKFUQvHWWKay6ARsNvZnzU7+a4apubw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.20 h1:YIvKIfPXQVp0EhXUV644kmQo6cQPPSRmC44A1HSoJeg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.20/go.mod h1:8W88sW3PjamQpKFUQvHWWKay6ARsNvZnzU7+a4apubw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.23 h1:c5+bNdV8E4fIPteWx4HZSkqI07oY9exbfQ7JH7Yx4PI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.23/go.mod h1:1jcUfF+FAOEwtIcNiHPaV4TSoZqkUIPzrohmD7fb95c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22 h1:LjFQf8hFuMO22HkV5VWGLBvmCLBCLPivUAmpdpnp4Vs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22/go.mod h1:xt0Au8yPIwYXf/GYPy/vl4K3CgwhfQMYbrH7DlUUIws=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.22 h1:ISLJ2BKXe4zzyZ7mp5ewKECiw0U7KpLgS3S6OxY9Cm0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.22/go.mod h1:QFVbqK54XArazLvn2wvWMRBi/jGrWii46qbr5DyPGjc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.2/go.mod h1:SXDHd6fI2RhqB7vmAzyYQCTQnpZrIprVJvYxpzW3JAM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3 h1:PVieHTwugdlHedlxLpYLQsOZAq736RScuEb/m4zhzc4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3/go.mod h1:XN3YcdmnWYZ3Hrnojvo5p2mc/wfF973nkq3ClXPDMHk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.12.1 h1:lQKN/LNa3qqu2cDOQZybP7oL4nMGGiFqob0jZJaR8/4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.12.1/go.mod h1:IgV8l3sj22nQDd5qcAGY0WenwCzCphqdbFOpfktZPrI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 h1:0bLhH6DRAqox+g0LatcjGKjjhU6Eudyys6HB6DJVPj8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1/go.mod h1:O1YSOg3aekZibh2SngvCRRG+cRHKKlYgxf/JBF/Kr/k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 h1:s49mSnsBZEXjfGBkRfmK+nPqzT7Lt3+t2SmAKNyHblw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.18.3/go.mod h1:b+psTJn33Q4qGoDaM7ZiOVVG8uVjGI6HaZ8WBHdgDgU=
|
||||||
|
github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
|
||||||
|
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
|
||||||
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
|
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
|
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
|
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||||
|
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
||||||
|
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||||
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
|
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||||
|
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||||
|
github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
|
||||||
|
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||||
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY=
|
||||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
|
||||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/maxbrunsfeld/counterfeiter/v6 v6.2.3/go.mod h1:1ftk08SazyElaaNvmqAfZWGwJzshjCfBXDLoQtPAMNk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
|
||||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
|
||||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
|
||||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
|
||||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
|
||||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
|
||||||
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
|
|
||||||
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
|
||||||
|
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a h1:/YWeLOBWYV5WAQORVPkZF3Pq9IppkcT72GKnWjNf5W8=
|
||||||
|
golang.org/x/exp v0.0.0-20230111222715-75897c7a292a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
|
||||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
|
||||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
|
||||||
|
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
|
golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
|
||||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200301222351-066e0c02454c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
|
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||||
|
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||||
|
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||||
|
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||||
|
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||||
|
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||||
|
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||||
|
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||||
|
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||||
|
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||||
|
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||||
modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE=
|
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||||
modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
|
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||||
|
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||||
|
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||||
|
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||||
|
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package base
|
|
||||||
|
|
||||||
import "regexp"
|
|
||||||
|
|
||||||
var (
|
|
||||||
UIDMatcher = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$")
|
|
||||||
)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package base
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUIDMatcher(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"", false},
|
|
||||||
{"-abc123", false},
|
|
||||||
{"012345678901234567890123456789", true},
|
|
||||||
{"1abc-123", true},
|
|
||||||
{"A123B456C789", true},
|
|
||||||
{"a", true},
|
|
||||||
{"ab", true},
|
|
||||||
{"a*b&c", false},
|
|
||||||
{"a--b", true},
|
|
||||||
{"a-1b-2c", true},
|
|
||||||
{"a1234567890123456789012345678901", true},
|
|
||||||
{"abc123", true},
|
|
||||||
{"abc123-", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(*testing.T) {
|
|
||||||
result := UIDMatcher.MatchString(test.input)
|
|
||||||
if result != test.expected {
|
|
||||||
t.Errorf("For input '%s', expected %v but got %v", test.input, test.expected, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
package profile
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Profile is the configuration to start main server.
|
|
||||||
type Profile struct {
|
|
||||||
// Mode can be "prod" or "dev" or "demo"
|
|
||||||
Mode string
|
|
||||||
// Addr is the binding address for server
|
|
||||||
Addr string
|
|
||||||
// Port is the binding port for server
|
|
||||||
Port int
|
|
||||||
// UNIXSock is the IPC binding path. Overrides Addr and Port
|
|
||||||
UNIXSock string
|
|
||||||
// Data is the data directory
|
|
||||||
Data string
|
|
||||||
// DSN points to where memos stores its own data
|
|
||||||
DSN string
|
|
||||||
// Driver is the database driver
|
|
||||||
// sqlite, mysql
|
|
||||||
Driver string
|
|
||||||
// Version is the current version of server
|
|
||||||
Version string
|
|
||||||
// InstanceURL is the url of your memos instance.
|
|
||||||
InstanceURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Profile) IsDev() bool {
|
|
||||||
return p.Mode != "prod"
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkDataDir(dataDir string) (string, error) {
|
|
||||||
// Convert to absolute path if relative path is supplied.
|
|
||||||
if !filepath.IsAbs(dataDir) {
|
|
||||||
relativeDir := filepath.Join(filepath.Dir(os.Args[0]), dataDir)
|
|
||||||
absDir, err := filepath.Abs(relativeDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
dataDir = absDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim trailing \ or / in case user supplies
|
|
||||||
dataDir = strings.TrimRight(dataDir, "\\/")
|
|
||||||
if _, err := os.Stat(dataDir); err != nil {
|
|
||||||
return "", errors.Wrapf(err, "unable to access data folder %s", dataDir)
|
|
||||||
}
|
|
||||||
return dataDir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Profile) Validate() error {
|
|
||||||
if p.Mode != "demo" && p.Mode != "dev" && p.Mode != "prod" {
|
|
||||||
p.Mode = "demo"
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.Mode == "prod" && p.Data == "" {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
p.Data = filepath.Join(os.Getenv("ProgramData"), "memos")
|
|
||||||
if _, err := os.Stat(p.Data); os.IsNotExist(err) {
|
|
||||||
if err := os.MkdirAll(p.Data, 0770); err != nil {
|
|
||||||
slog.Error("failed to create data directory", slog.String("data", p.Data), slog.String("error", err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.Data = "/var/opt/memos"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataDir, err := checkDataDir(p.Data)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to check dsn", slog.String("data", dataDir), slog.String("error", err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.Data = dataDir
|
|
||||||
if p.Driver == "sqlite" && p.DSN == "" {
|
|
||||||
dbFile := fmt.Sprintf("memos_%s.db", p.Mode)
|
|
||||||
p.DSN = filepath.Join(dataDir, dbFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"github.com/usememos/memos/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Fork from https://github.com/robfig/cron
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"runtime"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JobWrapper decorates the given Job with some behavior.
|
|
||||||
type JobWrapper func(Job) Job
|
|
||||||
|
|
||||||
// Chain is a sequence of JobWrappers that decorates submitted jobs with
|
|
||||||
// cross-cutting behaviors like logging or synchronization.
|
|
||||||
type Chain struct {
|
|
||||||
wrappers []JobWrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewChain returns a Chain consisting of the given JobWrappers.
|
|
||||||
func NewChain(c ...JobWrapper) Chain {
|
|
||||||
return Chain{c}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then decorates the given job with all JobWrappers in the chain.
|
|
||||||
//
|
|
||||||
// This:
|
|
||||||
//
|
|
||||||
// NewChain(m1, m2, m3).Then(job)
|
|
||||||
//
|
|
||||||
// is equivalent to:
|
|
||||||
//
|
|
||||||
// m1(m2(m3(job)))
|
|
||||||
func (c Chain) Then(j Job) Job {
|
|
||||||
for i := range c.wrappers {
|
|
||||||
j = c.wrappers[len(c.wrappers)-i-1](j)
|
|
||||||
}
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recover panics in wrapped jobs and log them with the provided logger.
|
|
||||||
func Recover(logger Logger) JobWrapper {
|
|
||||||
return func(j Job) Job {
|
|
||||||
return FuncJob(func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
const size = 64 << 10
|
|
||||||
buf := make([]byte, size)
|
|
||||||
buf = buf[:runtime.Stack(buf, false)]
|
|
||||||
err, ok := r.(error)
|
|
||||||
if !ok {
|
|
||||||
err = errors.New("panic: " + fmt.Sprint(r))
|
|
||||||
}
|
|
||||||
logger.Error(err, "panic", "stack", "...\n"+string(buf))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
j.Run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DelayIfStillRunning serializes jobs, delaying subsequent runs until the
|
|
||||||
// previous one is complete. Jobs running after a delay of more than a minute
|
|
||||||
// have the delay logged at Info.
|
|
||||||
func DelayIfStillRunning(logger Logger) JobWrapper {
|
|
||||||
return func(j Job) Job {
|
|
||||||
var mu sync.Mutex
|
|
||||||
return FuncJob(func() {
|
|
||||||
start := time.Now()
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if dur := time.Since(start); dur > time.Minute {
|
|
||||||
logger.Info("delay", "duration", dur)
|
|
||||||
}
|
|
||||||
j.Run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SkipIfStillRunning skips an invocation of the Job if a previous invocation is
|
|
||||||
// still running. It logs skips to the given logger at Info level.
|
|
||||||
func SkipIfStillRunning(logger Logger) JobWrapper {
|
|
||||||
return func(j Job) Job {
|
|
||||||
var ch = make(chan struct{}, 1)
|
|
||||||
ch <- struct{}{}
|
|
||||||
return FuncJob(func() {
|
|
||||||
select {
|
|
||||||
case v := <-ch:
|
|
||||||
defer func() { ch <- v }()
|
|
||||||
j.Run()
|
|
||||||
default:
|
|
||||||
logger.Info("skip")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
//nolint:all
|
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"reflect"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func appendingJob(slice *[]int, value int) Job {
|
|
||||||
var m sync.Mutex
|
|
||||||
return FuncJob(func() {
|
|
||||||
m.Lock()
|
|
||||||
*slice = append(*slice, value)
|
|
||||||
m.Unlock()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendingWrapper(slice *[]int, value int) JobWrapper {
|
|
||||||
return func(j Job) Job {
|
|
||||||
return FuncJob(func() {
|
|
||||||
appendingJob(slice, value).Run()
|
|
||||||
j.Run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChain(t *testing.T) {
|
|
||||||
var nums []int
|
|
||||||
var (
|
|
||||||
append1 = appendingWrapper(&nums, 1)
|
|
||||||
append2 = appendingWrapper(&nums, 2)
|
|
||||||
append3 = appendingWrapper(&nums, 3)
|
|
||||||
append4 = appendingJob(&nums, 4)
|
|
||||||
)
|
|
||||||
NewChain(append1, append2, append3).Then(append4).Run()
|
|
||||||
if !reflect.DeepEqual(nums, []int{1, 2, 3, 4}) {
|
|
||||||
t.Error("unexpected order of calls:", nums)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChainRecover(t *testing.T) {
|
|
||||||
panickingJob := FuncJob(func() {
|
|
||||||
panic("panickingJob panics")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("panic exits job by default", func(*testing.T) {
|
|
||||||
defer func() {
|
|
||||||
if err := recover(); err == nil {
|
|
||||||
t.Errorf("panic expected, but none received")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
NewChain().Then(panickingJob).
|
|
||||||
Run()
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Recovering JobWrapper recovers", func(*testing.T) {
|
|
||||||
NewChain(Recover(PrintfLogger(log.New(io.Discard, "", 0)))).
|
|
||||||
Then(panickingJob).
|
|
||||||
Run()
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("composed with the *IfStillRunning wrappers", func(*testing.T) {
|
|
||||||
NewChain(Recover(PrintfLogger(log.New(io.Discard, "", 0)))).
|
|
||||||
Then(panickingJob).
|
|
||||||
Run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type countJob struct {
|
|
||||||
m sync.Mutex
|
|
||||||
started int
|
|
||||||
done int
|
|
||||||
delay time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *countJob) Run() {
|
|
||||||
j.m.Lock()
|
|
||||||
j.started++
|
|
||||||
j.m.Unlock()
|
|
||||||
time.Sleep(j.delay)
|
|
||||||
j.m.Lock()
|
|
||||||
j.done++
|
|
||||||
j.m.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *countJob) Started() int {
|
|
||||||
defer j.m.Unlock()
|
|
||||||
j.m.Lock()
|
|
||||||
return j.started
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *countJob) Done() int {
|
|
||||||
defer j.m.Unlock()
|
|
||||||
j.m.Lock()
|
|
||||||
return j.done
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChainDelayIfStillRunning(t *testing.T) {
|
|
||||||
t.Run("runs immediately", func(*testing.T) {
|
|
||||||
var j countJob
|
|
||||||
wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j)
|
|
||||||
go wrappedJob.Run()
|
|
||||||
time.Sleep(2 * time.Millisecond) // Give the job 2ms to complete.
|
|
||||||
if c := j.Done(); c != 1 {
|
|
||||||
t.Errorf("expected job run once, immediately, got %d", c)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("second run immediate if first done", func(*testing.T) {
|
|
||||||
var j countJob
|
|
||||||
wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j)
|
|
||||||
go func() {
|
|
||||||
go wrappedJob.Run()
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
go wrappedJob.Run()
|
|
||||||
}()
|
|
||||||
time.Sleep(3 * time.Millisecond) // Give both jobs 3ms to complete.
|
|
||||||
if c := j.Done(); c != 2 {
|
|
||||||
t.Errorf("expected job run twice, immediately, got %d", c)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("second run delayed if first not done", func(*testing.T) {
|
|
||||||
var j countJob
|
|
||||||
j.delay = 10 * time.Millisecond
|
|
||||||
wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j)
|
|
||||||
go func() {
|
|
||||||
go wrappedJob.Run()
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
go wrappedJob.Run()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// After 5ms, the first job is still in progress, and the second job was
|
|
||||||
// run but should be waiting for it to finish.
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
started, done := j.Started(), j.Done()
|
|
||||||
if started != 1 || done != 0 {
|
|
||||||
t.Error("expected first job started, but not finished, got", started, done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the second job completes.
|
|
||||||
time.Sleep(25 * time.Millisecond)
|
|
||||||
started, done = j.Started(), j.Done()
|
|
||||||
if started != 2 || done != 2 {
|
|
||||||
t.Error("expected both jobs done, got", started, done)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChainSkipIfStillRunning(t *testing.T) {
|
|
||||||
t.Run("runs immediately", func(*testing.T) {
|
|
||||||
var j countJob
|
|
||||||
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
|
|
||||||
go wrappedJob.Run()
|
|
||||||
time.Sleep(2 * time.Millisecond) // Give the job 2ms to complete.
|
|
||||||
if c := j.Done(); c != 1 {
|
|
||||||
t.Errorf("expected job run once, immediately, got %d", c)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("second run immediate if first done", func(*testing.T) {
|
|
||||||
var j countJob
|
|
||||||
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
|
|
||||||
go func() {
|
|
||||||
go wrappedJob.Run()
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
go wrappedJob.Run()
|
|
||||||
}()
|
|
||||||
time.Sleep(3 * time.Millisecond) // Give both jobs 3ms to complete.
|
|
||||||
if c := j.Done(); c != 2 {
|
|
||||||
t.Errorf("expected job run twice, immediately, got %d", c)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("second run skipped if first not done", func(*testing.T) {
|
|
||||||
var j countJob
|
|
||||||
j.delay = 10 * time.Millisecond
|
|
||||||
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
|
|
||||||
go func() {
|
|
||||||
go wrappedJob.Run()
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
go wrappedJob.Run()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// After 5ms, the first job is still in progress, and the second job was
|
|
||||||
// already skipped.
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
started, done := j.Started(), j.Done()
|
|
||||||
if started != 1 || done != 0 {
|
|
||||||
t.Error("expected first job started, but not finished, got", started, done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the first job completes and second does not run.
|
|
||||||
time.Sleep(25 * time.Millisecond)
|
|
||||||
started, done = j.Started(), j.Done()
|
|
||||||
if started != 1 || done != 1 {
|
|
||||||
t.Error("expected second job skipped, got", started, done)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("skip 10 jobs on rapid fire", func(*testing.T) {
|
|
||||||
var j countJob
|
|
||||||
j.delay = 10 * time.Millisecond
|
|
||||||
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
|
|
||||||
for i := 0; i < 11; i++ {
|
|
||||||
go wrappedJob.Run()
|
|
||||||
}
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
done := j.Done()
|
|
||||||
if done != 1 {
|
|
||||||
t.Error("expected 1 jobs executed, 10 jobs dropped, got", done)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("different jobs independent", func(*testing.T) {
|
|
||||||
var j1, j2 countJob
|
|
||||||
j1.delay = 10 * time.Millisecond
|
|
||||||
j2.delay = 10 * time.Millisecond
|
|
||||||
chain := NewChain(SkipIfStillRunning(DiscardLogger))
|
|
||||||
wrappedJob1 := chain.Then(&j1)
|
|
||||||
wrappedJob2 := chain.Then(&j2)
|
|
||||||
for i := 0; i < 11; i++ {
|
|
||||||
go wrappedJob1.Run()
|
|
||||||
go wrappedJob2.Run()
|
|
||||||
}
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
var (
|
|
||||||
done1 = j1.Done()
|
|
||||||
done2 = j2.Done()
|
|
||||||
)
|
|
||||||
if done1 != 1 || done2 != 1 {
|
|
||||||
t.Error("expected both jobs executed once, got", done1, "and", done2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes".
|
|
||||||
// It does not support jobs more frequent than once a second.
|
|
||||||
type ConstantDelaySchedule struct {
|
|
||||||
Delay time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every returns a crontab Schedule that activates once every duration.
|
|
||||||
// Delays of less than a second are not supported (will round up to 1 second).
|
|
||||||
// Any fields less than a Second are truncated.
|
|
||||||
func Every(duration time.Duration) ConstantDelaySchedule {
|
|
||||||
if duration < time.Second {
|
|
||||||
duration = time.Second
|
|
||||||
}
|
|
||||||
return ConstantDelaySchedule{
|
|
||||||
Delay: duration - time.Duration(duration.Nanoseconds())%time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next returns the next time this should be run.
|
|
||||||
// This rounds so that the next activation time will be on the second.
|
|
||||||
func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time {
|
|
||||||
return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond)
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
//nolint:all
|
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConstantDelayNext(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
time string
|
|
||||||
delay time.Duration
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
// Simple cases
|
|
||||||
{"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"},
|
|
||||||
{"Mon Jul 9 14:59 2012", 15 * time.Minute, "Mon Jul 9 15:14 2012"},
|
|
||||||
{"Mon Jul 9 14:59:59 2012", 15 * time.Minute, "Mon Jul 9 15:14:59 2012"},
|
|
||||||
|
|
||||||
// Wrap around hours
|
|
||||||
{"Mon Jul 9 15:45 2012", 35 * time.Minute, "Mon Jul 9 16:20 2012"},
|
|
||||||
|
|
||||||
// Wrap around days
|
|
||||||
{"Mon Jul 9 23:46 2012", 14 * time.Minute, "Tue Jul 10 00:00 2012"},
|
|
||||||
{"Mon Jul 9 23:45 2012", 35 * time.Minute, "Tue Jul 10 00:20 2012"},
|
|
||||||
{"Mon Jul 9 23:35:51 2012", 44*time.Minute + 24*time.Second, "Tue Jul 10 00:20:15 2012"},
|
|
||||||
{"Mon Jul 9 23:35:51 2012", 25*time.Hour + 44*time.Minute + 24*time.Second, "Thu Jul 11 01:20:15 2012"},
|
|
||||||
|
|
||||||
// Wrap around months
|
|
||||||
{"Mon Jul 9 23:35 2012", 91*24*time.Hour + 25*time.Minute, "Thu Oct 9 00:00 2012"},
|
|
||||||
|
|
||||||
// Wrap around minute, hour, day, month, and year
|
|
||||||
{"Mon Dec 31 23:59:45 2012", 15 * time.Second, "Tue Jan 1 00:00:00 2013"},
|
|
||||||
|
|
||||||
// Round to nearest second on the delay
|
|
||||||
{"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"},
|
|
||||||
|
|
||||||
// Round up to 1 second if the duration is less.
|
|
||||||
{"Mon Jul 9 14:45:00 2012", 15 * time.Millisecond, "Mon Jul 9 14:45:01 2012"},
|
|
||||||
|
|
||||||
// Round to nearest second when calculating the next time.
|
|
||||||
{"Mon Jul 9 14:45:00.005 2012", 15 * time.Minute, "Mon Jul 9 15:00 2012"},
|
|
||||||
|
|
||||||
// Round to nearest second for both.
|
|
||||||
{"Mon Jul 9 14:45:00.005 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range tests {
|
|
||||||
actual := Every(c.delay).Next(getTime(c.time))
|
|
||||||
expected := getTime(c.expected)
|
|
||||||
if actual != expected {
|
|
||||||
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.delay, expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cron keeps track of any number of entries, invoking the associated func as
|
|
||||||
// specified by the schedule. It may be started, stopped, and the entries may
|
|
||||||
// be inspected while running.
|
|
||||||
type Cron struct {
|
|
||||||
entries []*Entry
|
|
||||||
chain Chain
|
|
||||||
stop chan struct{}
|
|
||||||
add chan *Entry
|
|
||||||
remove chan EntryID
|
|
||||||
snapshot chan chan []Entry
|
|
||||||
running bool
|
|
||||||
logger Logger
|
|
||||||
runningMu sync.Mutex
|
|
||||||
location *time.Location
|
|
||||||
parser ScheduleParser
|
|
||||||
nextID EntryID
|
|
||||||
jobWaiter sync.WaitGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScheduleParser is an interface for schedule spec parsers that return a Schedule.
|
|
||||||
type ScheduleParser interface {
|
|
||||||
Parse(spec string) (Schedule, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job is an interface for submitted cron jobs.
|
|
||||||
type Job interface {
|
|
||||||
Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule describes a job's duty cycle.
|
|
||||||
type Schedule interface {
|
|
||||||
// Next returns the next activation time, later than the given time.
|
|
||||||
// Next is invoked initially, and then each time the job is run.
|
|
||||||
Next(time.Time) time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// EntryID identifies an entry within a Cron instance.
|
|
||||||
type EntryID int
|
|
||||||
|
|
||||||
// Entry consists of a schedule and the func to execute on that schedule.
|
|
||||||
type Entry struct {
|
|
||||||
// ID is the cron-assigned ID of this entry, which may be used to look up a
|
|
||||||
// snapshot or remove it.
|
|
||||||
ID EntryID
|
|
||||||
|
|
||||||
// Schedule on which this job should be run.
|
|
||||||
Schedule Schedule
|
|
||||||
|
|
||||||
// Next time the job will run, or the zero time if Cron has not been
|
|
||||||
// started or this entry's schedule is unsatisfiable
|
|
||||||
Next time.Time
|
|
||||||
|
|
||||||
// Prev is the last time this job was run, or the zero time if never.
|
|
||||||
Prev time.Time
|
|
||||||
|
|
||||||
// WrappedJob is the thing to run when the Schedule is activated.
|
|
||||||
WrappedJob Job
|
|
||||||
|
|
||||||
// Job is the thing that was submitted to cron.
|
|
||||||
// It is kept around so that user code that needs to get at the job later,
|
|
||||||
// e.g. via Entries() can do so.
|
|
||||||
Job Job
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid returns true if this is not the zero entry.
|
|
||||||
func (e Entry) Valid() bool { return e.ID != 0 }
|
|
||||||
|
|
||||||
// byTime is a wrapper for sorting the entry array by time
|
|
||||||
// (with zero time at the end).
|
|
||||||
type byTime []*Entry
|
|
||||||
|
|
||||||
func (s byTime) Len() int { return len(s) }
|
|
||||||
func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
|
||||||
func (s byTime) Less(i, j int) bool {
|
|
||||||
// Two zero times should return false.
|
|
||||||
// Otherwise, zero is "greater" than any other time.
|
|
||||||
// (To sort it at the end of the list.)
|
|
||||||
if s[i].Next.IsZero() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if s[j].Next.IsZero() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return s[i].Next.Before(s[j].Next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New returns a new Cron job runner, modified by the given options.
|
|
||||||
//
|
|
||||||
// Available Settings
|
|
||||||
//
|
|
||||||
// Time Zone
|
|
||||||
// Description: The time zone in which schedules are interpreted
|
|
||||||
// Default: time.Local
|
|
||||||
//
|
|
||||||
// Parser
|
|
||||||
// Description: Parser converts cron spec strings into cron.Schedules.
|
|
||||||
// Default: Accepts this spec: https://en.wikipedia.org/wiki/Cron
|
|
||||||
//
|
|
||||||
// Chain
|
|
||||||
// Description: Wrap submitted jobs to customize behavior.
|
|
||||||
// Default: A chain that recovers panics and logs them to stderr.
|
|
||||||
//
|
|
||||||
// See "cron.With*" to modify the default behavior.
|
|
||||||
func New(opts ...Option) *Cron {
|
|
||||||
c := &Cron{
|
|
||||||
entries: nil,
|
|
||||||
chain: NewChain(),
|
|
||||||
add: make(chan *Entry),
|
|
||||||
stop: make(chan struct{}),
|
|
||||||
snapshot: make(chan chan []Entry),
|
|
||||||
remove: make(chan EntryID),
|
|
||||||
running: false,
|
|
||||||
runningMu: sync.Mutex{},
|
|
||||||
logger: DefaultLogger,
|
|
||||||
location: time.Local,
|
|
||||||
parser: standardParser,
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(c)
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// FuncJob is a wrapper that turns a func() into a cron.Job.
|
|
||||||
type FuncJob func()
|
|
||||||
|
|
||||||
func (f FuncJob) Run() { f() }
|
|
||||||
|
|
||||||
// AddFunc adds a func to the Cron to be run on the given schedule.
|
|
||||||
// The spec is parsed using the time zone of this Cron instance as the default.
|
|
||||||
// An opaque ID is returned that can be used to later remove it.
|
|
||||||
func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) {
|
|
||||||
return c.AddJob(spec, FuncJob(cmd))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddJob adds a Job to the Cron to be run on the given schedule.
|
|
||||||
// The spec is parsed using the time zone of this Cron instance as the default.
|
|
||||||
// An opaque ID is returned that can be used to later remove it.
|
|
||||||
func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) {
|
|
||||||
schedule, err := c.parser.Parse(spec)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return c.Schedule(schedule, cmd), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule adds a Job to the Cron to be run on the given schedule.
|
|
||||||
// The job is wrapped with the configured Chain.
|
|
||||||
func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID {
|
|
||||||
c.runningMu.Lock()
|
|
||||||
defer c.runningMu.Unlock()
|
|
||||||
c.nextID++
|
|
||||||
entry := &Entry{
|
|
||||||
ID: c.nextID,
|
|
||||||
Schedule: schedule,
|
|
||||||
WrappedJob: c.chain.Then(cmd),
|
|
||||||
Job: cmd,
|
|
||||||
}
|
|
||||||
if !c.running {
|
|
||||||
c.entries = append(c.entries, entry)
|
|
||||||
} else {
|
|
||||||
c.add <- entry
|
|
||||||
}
|
|
||||||
return entry.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entries returns a snapshot of the cron entries.
|
|
||||||
func (c *Cron) Entries() []Entry {
|
|
||||||
c.runningMu.Lock()
|
|
||||||
defer c.runningMu.Unlock()
|
|
||||||
if c.running {
|
|
||||||
replyChan := make(chan []Entry, 1)
|
|
||||||
c.snapshot <- replyChan
|
|
||||||
return <-replyChan
|
|
||||||
}
|
|
||||||
return c.entrySnapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Location gets the time zone location.
|
|
||||||
func (c *Cron) Location() *time.Location {
|
|
||||||
return c.location
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entry returns a snapshot of the given entry, or nil if it couldn't be found.
|
|
||||||
func (c *Cron) Entry(id EntryID) Entry {
|
|
||||||
for _, entry := range c.Entries() {
|
|
||||||
if id == entry.ID {
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Entry{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove an entry from being run in the future.
|
|
||||||
func (c *Cron) Remove(id EntryID) {
|
|
||||||
c.runningMu.Lock()
|
|
||||||
defer c.runningMu.Unlock()
|
|
||||||
if c.running {
|
|
||||||
c.remove <- id
|
|
||||||
} else {
|
|
||||||
c.removeEntry(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the cron scheduler in its own goroutine, or no-op if already started.
|
|
||||||
func (c *Cron) Start() {
|
|
||||||
c.runningMu.Lock()
|
|
||||||
defer c.runningMu.Unlock()
|
|
||||||
if c.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.running = true
|
|
||||||
go c.runScheduler()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the cron scheduler, or no-op if already running.
|
|
||||||
func (c *Cron) Run() {
|
|
||||||
c.runningMu.Lock()
|
|
||||||
if c.running {
|
|
||||||
c.runningMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.running = true
|
|
||||||
c.runningMu.Unlock()
|
|
||||||
c.runScheduler()
|
|
||||||
}
|
|
||||||
|
|
||||||
// runScheduler runs the scheduler.. this is private just due to the need to synchronize
|
|
||||||
// access to the 'running' state variable.
|
|
||||||
func (c *Cron) runScheduler() {
|
|
||||||
c.logger.Info("start")
|
|
||||||
|
|
||||||
// Figure out the next activation times for each entry.
|
|
||||||
now := c.now()
|
|
||||||
for _, entry := range c.entries {
|
|
||||||
entry.Next = entry.Schedule.Next(now)
|
|
||||||
c.logger.Info("schedule", "now", now, "entry", entry.ID, "next", entry.Next)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
// Determine the next entry to run.
|
|
||||||
sort.Sort(byTime(c.entries))
|
|
||||||
|
|
||||||
var timer *time.Timer
|
|
||||||
if len(c.entries) == 0 || c.entries[0].Next.IsZero() {
|
|
||||||
// If there are no entries yet, just sleep - it still handles new entries
|
|
||||||
// and stop requests.
|
|
||||||
timer = time.NewTimer(100000 * time.Hour)
|
|
||||||
} else {
|
|
||||||
timer = time.NewTimer(c.entries[0].Next.Sub(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case now = <-timer.C:
|
|
||||||
now = now.In(c.location)
|
|
||||||
c.logger.Info("wake", "now", now)
|
|
||||||
|
|
||||||
// Run every entry whose next time was less than now
|
|
||||||
for _, e := range c.entries {
|
|
||||||
if e.Next.After(now) || e.Next.IsZero() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
c.startJob(e.WrappedJob)
|
|
||||||
e.Prev = e.Next
|
|
||||||
e.Next = e.Schedule.Next(now)
|
|
||||||
c.logger.Info("run", "now", now, "entry", e.ID, "next", e.Next)
|
|
||||||
}
|
|
||||||
|
|
||||||
case newEntry := <-c.add:
|
|
||||||
timer.Stop()
|
|
||||||
now = c.now()
|
|
||||||
newEntry.Next = newEntry.Schedule.Next(now)
|
|
||||||
c.entries = append(c.entries, newEntry)
|
|
||||||
c.logger.Info("added", "now", now, "entry", newEntry.ID, "next", newEntry.Next)
|
|
||||||
|
|
||||||
case replyChan := <-c.snapshot:
|
|
||||||
replyChan <- c.entrySnapshot()
|
|
||||||
continue
|
|
||||||
|
|
||||||
case <-c.stop:
|
|
||||||
timer.Stop()
|
|
||||||
c.logger.Info("stop")
|
|
||||||
return
|
|
||||||
|
|
||||||
case id := <-c.remove:
|
|
||||||
timer.Stop()
|
|
||||||
now = c.now()
|
|
||||||
c.removeEntry(id)
|
|
||||||
c.logger.Info("removed", "entry", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startJob runs the given job in a new goroutine.
|
|
||||||
func (c *Cron) startJob(j Job) {
|
|
||||||
c.jobWaiter.Go(func() {
|
|
||||||
j.Run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// now returns current time in c location.
|
|
||||||
func (c *Cron) now() time.Time {
|
|
||||||
return time.Now().In(c.location)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the cron scheduler if it is running; otherwise it does nothing.
|
|
||||||
// A context is returned so the caller can wait for running jobs to complete.
|
|
||||||
func (c *Cron) Stop() context.Context {
|
|
||||||
c.runningMu.Lock()
|
|
||||||
defer c.runningMu.Unlock()
|
|
||||||
if c.running {
|
|
||||||
c.stop <- struct{}{}
|
|
||||||
c.running = false
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
go func() {
|
|
||||||
c.jobWaiter.Wait()
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// entrySnapshot returns a copy of the current cron entry list.
|
|
||||||
func (c *Cron) entrySnapshot() []Entry {
|
|
||||||
var entries = make([]Entry, len(c.entries))
|
|
||||||
for i, e := range c.entries {
|
|
||||||
entries[i] = *e
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Cron) removeEntry(id EntryID) {
|
|
||||||
var entries []*Entry
|
|
||||||
for _, e := range c.entries {
|
|
||||||
if e.ID != id {
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.entries = entries
|
|
||||||
}
|
|
||||||
|
|
@ -1,702 +0,0 @@
|
||||||
//nolint:all
|
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Many tests schedule a job for every second, and then wait at most a second
|
|
||||||
// for it to run. This amount is just slightly larger than 1 second to
|
|
||||||
// compensate for a few milliseconds of runtime.
|
|
||||||
const OneSecond = 1*time.Second + 50*time.Millisecond
|
|
||||||
|
|
||||||
type syncWriter struct {
|
|
||||||
wr bytes.Buffer
|
|
||||||
m sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sw *syncWriter) Write(data []byte) (n int, err error) {
|
|
||||||
sw.m.Lock()
|
|
||||||
n, err = sw.wr.Write(data)
|
|
||||||
sw.m.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sw *syncWriter) String() string {
|
|
||||||
sw.m.Lock()
|
|
||||||
defer sw.m.Unlock()
|
|
||||||
return sw.wr.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBufLogger(sw *syncWriter) Logger {
|
|
||||||
return PrintfLogger(log.New(sw, "", log.LstdFlags))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFuncPanicRecovery(t *testing.T) {
|
|
||||||
var buf syncWriter
|
|
||||||
cron := New(WithParser(secondParser),
|
|
||||||
WithChain(Recover(newBufLogger(&buf))))
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
cron.AddFunc("* * * * * ?", func() {
|
|
||||||
panic("YOLO")
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
if !strings.Contains(buf.String(), "YOLO") {
|
|
||||||
t.Error("expected a panic to be logged, got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type DummyJob struct{}
|
|
||||||
|
|
||||||
func (DummyJob) Run() {
|
|
||||||
panic("YOLO")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJobPanicRecovery(t *testing.T) {
|
|
||||||
var job DummyJob
|
|
||||||
|
|
||||||
var buf syncWriter
|
|
||||||
cron := New(WithParser(secondParser),
|
|
||||||
WithChain(Recover(newBufLogger(&buf))))
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
cron.AddJob("* * * * * ?", job)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
if !strings.Contains(buf.String(), "YOLO") {
|
|
||||||
t.Error("expected a panic to be logged, got none")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start and stop cron with no entries.
|
|
||||||
func TestNoEntries(t *testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.Start()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
t.Fatal("expected cron will be stopped immediately")
|
|
||||||
case <-stop(cron):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start, stop, then add an entry. Verify entry doesn't run.
|
|
||||||
func TestStopCausesJobsToNotRun(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.Start()
|
|
||||||
cron.Stop()
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
// No job ran!
|
|
||||||
case <-wait(wg):
|
|
||||||
t.Fatal("expected stopped cron does not run any job")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a job, start cron, expect it runs.
|
|
||||||
func TestAddBeforeRunning(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
// Give cron 2 seconds to run our job (which is always activated).
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
t.Fatal("expected job runs")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start cron, add a job, expect it runs.
|
|
||||||
func TestAddWhileRunning(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
t.Fatal("expected job runs")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test for #34. Adding a job after calling start results in multiple job invocations
|
|
||||||
func TestAddWhileRunningWithDelay(t *testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
var calls int64
|
|
||||||
cron.AddFunc("* * * * * *", func() { atomic.AddInt64(&calls, 1) })
|
|
||||||
|
|
||||||
<-time.After(OneSecond)
|
|
||||||
if atomic.LoadInt64(&calls) != 1 {
|
|
||||||
t.Errorf("called %d times, expected 1\n", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a job, remove a job, start cron, expect nothing runs.
|
|
||||||
func TestRemoveBeforeRunning(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
cron.Remove(id)
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
// Success, shouldn't run
|
|
||||||
case <-wait(wg):
|
|
||||||
t.FailNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start cron, add a job, remove it, expect it doesn't run.
|
|
||||||
func TestRemoveWhileRunning(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
cron.Remove(id)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
case <-wait(wg):
|
|
||||||
t.FailNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test timing with Entries.
|
|
||||||
func TestSnapshotEntries(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := New()
|
|
||||||
cron.AddFunc("@every 2s", func() { wg.Done() })
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
// Cron should fire in 2 seconds. After 1 second, call Entries.
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
cron.Entries()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Even though Entries was called, the cron should fire at the 2 second mark.
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
t.Error("expected job runs at 2 second mark")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that the entries are correctly sorted.
|
|
||||||
// Add a bunch of long-in-the-future entries, and an immediate entry, and ensure
|
|
||||||
// that the immediate entry runs immediately.
|
|
||||||
// Also: Test that multiple jobs run in the same instant.
|
|
||||||
func TestMultipleEntries(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("0 0 0 1 1 ?", func() {})
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
id1, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
|
|
||||||
id2, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
|
|
||||||
cron.AddFunc("0 0 0 31 12 ?", func() {})
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
|
|
||||||
cron.Remove(id1)
|
|
||||||
cron.Start()
|
|
||||||
cron.Remove(id2)
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
t.Error("expected job run in proper order")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test running the same job twice.
|
|
||||||
func TestRunningJobTwice(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("0 0 0 1 1 ?", func() {})
|
|
||||||
cron.AddFunc("0 0 0 31 12 ?", func() {})
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(2 * OneSecond):
|
|
||||||
t.Error("expected job fires 2 times")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunningMultipleSchedules(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("0 0 0 1 1 ?", func() {})
|
|
||||||
cron.AddFunc("0 0 0 31 12 ?", func() {})
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
cron.Schedule(Every(time.Minute), FuncJob(func() {}))
|
|
||||||
cron.Schedule(Every(time.Second), FuncJob(func() { wg.Done() }))
|
|
||||||
cron.Schedule(Every(time.Hour), FuncJob(func() {}))
|
|
||||||
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(2 * OneSecond):
|
|
||||||
t.Error("expected job fires 2 times")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that the cron is run in the local time zone (as opposed to UTC).
|
|
||||||
func TestLocalTimezone(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
// FIX: Issue #205
|
|
||||||
// This calculation doesn't work in seconds 58 or 59.
|
|
||||||
// Take the easy way out and sleep.
|
|
||||||
if now.Second() >= 58 {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
now = time.Now()
|
|
||||||
}
|
|
||||||
spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
|
|
||||||
now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc(spec, func() { wg.Done() })
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond * 2):
|
|
||||||
t.Error("expected job fires 2 times")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that the cron is run in the given time zone (as opposed to local).
|
|
||||||
func TestNonLocalTimezone(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
loc, err := time.LoadLocation("Atlantic/Cape_Verde")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Failed to load time zone Atlantic/Cape_Verde: %+v", err)
|
|
||||||
t.Fail()
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().In(loc)
|
|
||||||
// FIX: Issue #205
|
|
||||||
// This calculation doesn't work in seconds 58 or 59.
|
|
||||||
// Take the easy way out and sleep.
|
|
||||||
if now.Second() >= 58 {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
now = time.Now().In(loc)
|
|
||||||
}
|
|
||||||
spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
|
|
||||||
now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
|
|
||||||
|
|
||||||
cron := New(WithLocation(loc), WithParser(secondParser))
|
|
||||||
cron.AddFunc(spec, func() { wg.Done() })
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond * 2):
|
|
||||||
t.Error("expected job fires 2 times")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that calling stop before start silently returns without
|
|
||||||
// blocking the stop channel.
|
|
||||||
func TestStopWithoutStart(t *testing.T) {
|
|
||||||
cron := New()
|
|
||||||
cron.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
type testJob struct {
|
|
||||||
wg *sync.WaitGroup
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t testJob) Run() {
|
|
||||||
t.wg.Done()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that adding an invalid job spec returns an error
|
|
||||||
func TestInvalidJobSpec(t *testing.T) {
|
|
||||||
cron := New()
|
|
||||||
_, err := cron.AddJob("this will not parse", nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("expected an error with invalid spec, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test blocking run method behaves as Start()
|
|
||||||
func TestBlockingRun(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("* * * * * ?", func() { wg.Done() })
|
|
||||||
|
|
||||||
var unblockChan = make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
cron.Run()
|
|
||||||
close(unblockChan)
|
|
||||||
}()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
t.Error("expected job fires")
|
|
||||||
case <-unblockChan:
|
|
||||||
t.Error("expected that Run() blocks")
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that double-running is a no-op
|
|
||||||
func TestStartNoop(t *testing.T) {
|
|
||||||
var tickChan = make(chan struct{}, 2)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("* * * * * ?", func() {
|
|
||||||
tickChan <- struct{}{}
|
|
||||||
})
|
|
||||||
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
// Wait for the first firing to ensure the runner is going
|
|
||||||
<-tickChan
|
|
||||||
|
|
||||||
cron.Start()
|
|
||||||
|
|
||||||
<-tickChan
|
|
||||||
|
|
||||||
// Fail if this job fires again in a short period, indicating a double-run
|
|
||||||
select {
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
case <-tickChan:
|
|
||||||
t.Error("expected job fires exactly twice")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple test using Runnables.
|
|
||||||
func TestJob(t *testing.T) {
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddJob("0 0 0 30 Feb ?", testJob{wg, "job0"})
|
|
||||||
cron.AddJob("0 0 0 1 1 ?", testJob{wg, "job1"})
|
|
||||||
job2, _ := cron.AddJob("* * * * * ?", testJob{wg, "job2"})
|
|
||||||
cron.AddJob("1 0 0 1 1 ?", testJob{wg, "job3"})
|
|
||||||
cron.Schedule(Every(5*time.Second+5*time.Nanosecond), testJob{wg, "job4"})
|
|
||||||
job5 := cron.Schedule(Every(5*time.Minute), testJob{wg, "job5"})
|
|
||||||
|
|
||||||
// Test getting an Entry pre-Start.
|
|
||||||
if actualName := cron.Entry(job2).Job.(testJob).name; actualName != "job2" {
|
|
||||||
t.Error("wrong job retrieved:", actualName)
|
|
||||||
}
|
|
||||||
if actualName := cron.Entry(job5).Job.(testJob).name; actualName != "job5" {
|
|
||||||
t.Error("wrong job retrieved:", actualName)
|
|
||||||
}
|
|
||||||
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(OneSecond):
|
|
||||||
t.FailNow()
|
|
||||||
case <-wait(wg):
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the entries are in the right order.
|
|
||||||
expecteds := []string{"job2", "job4", "job5", "job1", "job3", "job0"}
|
|
||||||
|
|
||||||
var actuals []string
|
|
||||||
for _, entry := range cron.Entries() {
|
|
||||||
actuals = append(actuals, entry.Job.(testJob).name)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, expected := range expecteds {
|
|
||||||
if actuals[i] != expected {
|
|
||||||
t.Fatalf("Jobs not in the right order. (expected) %s != %s (actual)", expecteds, actuals)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test getting Entries.
|
|
||||||
if actualName := cron.Entry(job2).Job.(testJob).name; actualName != "job2" {
|
|
||||||
t.Error("wrong job retrieved:", actualName)
|
|
||||||
}
|
|
||||||
if actualName := cron.Entry(job5).Job.(testJob).name; actualName != "job5" {
|
|
||||||
t.Error("wrong job retrieved:", actualName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue #206
|
|
||||||
// Ensure that the next run of a job after removing an entry is accurate.
|
|
||||||
func TestScheduleAfterRemoval(t *testing.T) {
|
|
||||||
var wg1 sync.WaitGroup
|
|
||||||
var wg2 sync.WaitGroup
|
|
||||||
wg1.Add(1)
|
|
||||||
wg2.Add(1)
|
|
||||||
|
|
||||||
// The first time this job is run, set a timer and remove the other job
|
|
||||||
// 750ms later. Correct behavior would be to still run the job again in
|
|
||||||
// 250ms, but the bug would cause it to run instead 1s later.
|
|
||||||
|
|
||||||
var calls int
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
cron := newWithSeconds()
|
|
||||||
hourJob := cron.Schedule(Every(time.Hour), FuncJob(func() {}))
|
|
||||||
cron.Schedule(Every(time.Second), FuncJob(func() {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
switch calls {
|
|
||||||
case 0:
|
|
||||||
wg1.Done()
|
|
||||||
calls++
|
|
||||||
case 1:
|
|
||||||
time.Sleep(750 * time.Millisecond)
|
|
||||||
cron.Remove(hourJob)
|
|
||||||
calls++
|
|
||||||
case 2:
|
|
||||||
calls++
|
|
||||||
wg2.Done()
|
|
||||||
case 3:
|
|
||||||
panic("unexpected 3rd call")
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
|
|
||||||
// the first run might be any length of time 0 - 1s, since the schedule
|
|
||||||
// rounds to the second. wait for the first run to true up.
|
|
||||||
wg1.Wait()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(2 * OneSecond):
|
|
||||||
t.Error("expected job fires 2 times")
|
|
||||||
case <-wait(&wg2):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ZeroSchedule struct{}
|
|
||||||
|
|
||||||
func (*ZeroSchedule) Next(time.Time) time.Time {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests that job without time does not run
|
|
||||||
func TestJobWithZeroTimeDoesNotRun(t *testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
var calls int64
|
|
||||||
cron.AddFunc("* * * * * *", func() { atomic.AddInt64(&calls, 1) })
|
|
||||||
cron.Schedule(new(ZeroSchedule), FuncJob(func() { t.Error("expected zero task will not run") }))
|
|
||||||
cron.Start()
|
|
||||||
defer cron.Stop()
|
|
||||||
<-time.After(OneSecond)
|
|
||||||
if atomic.LoadInt64(&calls) != 1 {
|
|
||||||
t.Errorf("called %d times, expected 1\n", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStopAndWait(t *testing.T) {
|
|
||||||
t.Run("nothing running, returns immediately", func(*testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.Start()
|
|
||||||
ctx := cron.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
t.Error("context was not done immediately")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("repeated calls to Stop", func(*testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.Start()
|
|
||||||
_ = cron.Stop()
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
ctx := cron.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
t.Error("context was not done immediately")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("a couple fast jobs added, still returns immediately", func(*testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
cron.Start()
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
ctx := cron.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
t.Error("context was not done immediately")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("a couple fast jobs and a slow job added, waits for slow job", func(*testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
cron.Start()
|
|
||||||
cron.AddFunc("* * * * * *", func() { time.Sleep(2 * time.Second) })
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
|
|
||||||
ctx := cron.Stop()
|
|
||||||
|
|
||||||
// Verify that it is not done for at least 750ms
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
t.Error("context was done too quickly immediately")
|
|
||||||
case <-time.After(750 * time.Millisecond):
|
|
||||||
// expected, because the job sleeping for 1 second is still running
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that it IS done in the next 500ms (giving 250ms buffer)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// expected
|
|
||||||
case <-time.After(1500 * time.Millisecond):
|
|
||||||
t.Error("context not done after job should have completed")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("repeated calls to stop, waiting for completion and after", func(*testing.T) {
|
|
||||||
cron := newWithSeconds()
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
cron.AddFunc("* * * * * *", func() { time.Sleep(2 * time.Second) })
|
|
||||||
cron.Start()
|
|
||||||
cron.AddFunc("* * * * * *", func() {})
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
ctx := cron.Stop()
|
|
||||||
ctx2 := cron.Stop()
|
|
||||||
|
|
||||||
// Verify that it is not done for at least 1500ms
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
t.Error("context was done too quickly immediately")
|
|
||||||
case <-ctx2.Done():
|
|
||||||
t.Error("context2 was done too quickly immediately")
|
|
||||||
case <-time.After(1500 * time.Millisecond):
|
|
||||||
// expected, because the job sleeping for 2 seconds is still running
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that it IS done in the next 1s (giving 500ms buffer)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// expected
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Error("context not done after job should have completed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that ctx2 is also done.
|
|
||||||
select {
|
|
||||||
case <-ctx2.Done():
|
|
||||||
// expected
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
t.Error("context2 not done even though context1 is")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that a new context retrieved from stop is immediately done.
|
|
||||||
ctx3 := cron.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx3.Done():
|
|
||||||
// expected
|
|
||||||
case <-time.After(time.Millisecond):
|
|
||||||
t.Error("context not done even when cron Stop is completed")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultiThreadedStartAndStop(t *testing.T) {
|
|
||||||
cron := New()
|
|
||||||
go cron.Run()
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
cron.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func wait(wg *sync.WaitGroup) chan bool {
|
|
||||||
ch := make(chan bool)
|
|
||||||
go func() {
|
|
||||||
wg.Wait()
|
|
||||||
ch <- true
|
|
||||||
}()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop(cron *Cron) chan bool {
|
|
||||||
ch := make(chan bool)
|
|
||||||
go func() {
|
|
||||||
cron.Stop()
|
|
||||||
ch <- true
|
|
||||||
}()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// newWithSeconds returns a Cron with the seconds field enabled.
|
|
||||||
func newWithSeconds() *Cron {
|
|
||||||
return New(WithParser(secondParser), WithChain())
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultLogger is used by Cron if none is specified.
|
|
||||||
var DefaultLogger = PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))
|
|
||||||
|
|
||||||
// DiscardLogger can be used by callers to discard all log messages.
|
|
||||||
var DiscardLogger = PrintfLogger(log.New(io.Discard, "", 0))
|
|
||||||
|
|
||||||
// Logger is the interface used in this package for logging, so that any backend
|
|
||||||
// can be plugged in. It is a subset of the github.com/go-logr/logr interface.
|
|
||||||
type Logger interface {
|
|
||||||
// Info logs routine messages about cron's operation.
|
|
||||||
Info(msg string, keysAndValues ...interface{})
|
|
||||||
// Error logs an error condition.
|
|
||||||
Error(err error, msg string, keysAndValues ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrintfLogger wraps a Printf-based logger (such as the standard library "log")
|
|
||||||
// into an implementation of the Logger interface which logs errors only.
|
|
||||||
func PrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger {
|
|
||||||
return printfLogger{l, false}
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerbosePrintfLogger wraps a Printf-based logger (such as the standard library
|
|
||||||
// "log") into an implementation of the Logger interface which logs everything.
|
|
||||||
func VerbosePrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger {
|
|
||||||
return printfLogger{l, true}
|
|
||||||
}
|
|
||||||
|
|
||||||
type printfLogger struct {
|
|
||||||
logger interface{ Printf(string, ...interface{}) }
|
|
||||||
logInfo bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pl printfLogger) Info(msg string, keysAndValues ...interface{}) {
|
|
||||||
if pl.logInfo {
|
|
||||||
keysAndValues = formatTimes(keysAndValues)
|
|
||||||
pl.logger.Printf(
|
|
||||||
formatString(len(keysAndValues)),
|
|
||||||
append([]interface{}{msg}, keysAndValues...)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pl printfLogger) Error(err error, msg string, keysAndValues ...interface{}) {
|
|
||||||
keysAndValues = formatTimes(keysAndValues)
|
|
||||||
pl.logger.Printf(
|
|
||||||
formatString(len(keysAndValues)+2),
|
|
||||||
append([]interface{}{msg, "error", err}, keysAndValues...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatString returns a logfmt-like format string for the number of
|
|
||||||
// key/values.
|
|
||||||
func formatString(numKeysAndValues int) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("%s")
|
|
||||||
if numKeysAndValues > 0 {
|
|
||||||
sb.WriteString(", ")
|
|
||||||
}
|
|
||||||
for i := 0; i < numKeysAndValues/2; i++ {
|
|
||||||
if i > 0 {
|
|
||||||
sb.WriteString(", ")
|
|
||||||
}
|
|
||||||
sb.WriteString("%v=%v")
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTimes formats any time.Time values as RFC3339.
|
|
||||||
func formatTimes(keysAndValues []interface{}) []interface{} {
|
|
||||||
var formattedArgs []interface{}
|
|
||||||
for _, arg := range keysAndValues {
|
|
||||||
if t, ok := arg.(time.Time); ok {
|
|
||||||
arg = t.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
formattedArgs = append(formattedArgs, arg)
|
|
||||||
}
|
|
||||||
return formattedArgs
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Option represents a modification to the default behavior of a Cron.
|
|
||||||
type Option func(*Cron)
|
|
||||||
|
|
||||||
// WithLocation overrides the timezone of the cron instance.
|
|
||||||
func WithLocation(loc *time.Location) Option {
|
|
||||||
return func(c *Cron) {
|
|
||||||
c.location = loc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSeconds overrides the parser used for interpreting job schedules to
|
|
||||||
// include a seconds field as the first one.
|
|
||||||
func WithSeconds() Option {
|
|
||||||
return WithParser(NewParser(
|
|
||||||
Second | Minute | Hour | Dom | Month | Dow | Descriptor,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithParser overrides the parser used for interpreting job schedules.
|
|
||||||
func WithParser(p ScheduleParser) Option {
|
|
||||||
return func(c *Cron) {
|
|
||||||
c.parser = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithChain specifies Job wrappers to apply to all jobs added to this cron.
|
|
||||||
// Refer to the Chain* functions in this package for provided wrappers.
|
|
||||||
func WithChain(wrappers ...JobWrapper) Option {
|
|
||||||
return func(c *Cron) {
|
|
||||||
c.chain = NewChain(wrappers...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLogger uses the provided logger.
|
|
||||||
func WithLogger(logger Logger) Option {
|
|
||||||
return func(c *Cron) {
|
|
||||||
c.logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
//nolint:all
|
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWithLocation(t *testing.T) {
|
|
||||||
c := New(WithLocation(time.UTC))
|
|
||||||
if c.location != time.UTC {
|
|
||||||
t.Errorf("expected UTC, got %v", c.location)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithParser(t *testing.T) {
|
|
||||||
var parser = NewParser(Dow)
|
|
||||||
c := New(WithParser(parser))
|
|
||||||
if c.parser != parser {
|
|
||||||
t.Error("expected provided parser")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithVerboseLogger(t *testing.T) {
|
|
||||||
var buf syncWriter
|
|
||||||
var logger = log.New(&buf, "", log.LstdFlags)
|
|
||||||
c := New(WithLogger(VerbosePrintfLogger(logger)))
|
|
||||||
if c.logger.(printfLogger).logger != logger {
|
|
||||||
t.Error("expected provided logger")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.AddFunc("@every 1s", func() {})
|
|
||||||
c.Start()
|
|
||||||
time.Sleep(OneSecond)
|
|
||||||
c.Stop()
|
|
||||||
out := buf.String()
|
|
||||||
if !strings.Contains(out, "schedule,") ||
|
|
||||||
!strings.Contains(out, "run,") {
|
|
||||||
t.Error("expected to see some actions, got:", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,437 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Configuration options for creating a parser. Most options specify which
|
|
||||||
// fields should be included, while others enable features. If a field is not
|
|
||||||
// included the parser will assume a default value. These options do not change
|
|
||||||
// the order fields are parse in.
|
|
||||||
type ParseOption int
|
|
||||||
|
|
||||||
const (
|
|
||||||
Second ParseOption = 1 << iota // Seconds field, default 0
|
|
||||||
SecondOptional // Optional seconds field, default 0
|
|
||||||
Minute // Minutes field, default 0
|
|
||||||
Hour // Hours field, default 0
|
|
||||||
Dom // Day of month field, default *
|
|
||||||
Month // Month field, default *
|
|
||||||
Dow // Day of week field, default *
|
|
||||||
DowOptional // Optional day of week field, default *
|
|
||||||
Descriptor // Allow descriptors such as @monthly, @weekly, etc.
|
|
||||||
)
|
|
||||||
|
|
||||||
var places = []ParseOption{
|
|
||||||
Second,
|
|
||||||
Minute,
|
|
||||||
Hour,
|
|
||||||
Dom,
|
|
||||||
Month,
|
|
||||||
Dow,
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaults = []string{
|
|
||||||
"0",
|
|
||||||
"0",
|
|
||||||
"0",
|
|
||||||
"*",
|
|
||||||
"*",
|
|
||||||
"*",
|
|
||||||
}
|
|
||||||
|
|
||||||
// A custom Parser that can be configured.
|
|
||||||
type Parser struct {
|
|
||||||
options ParseOption
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewParser creates a Parser with custom options.
|
|
||||||
//
|
|
||||||
// It panics if more than one Optional is given, since it would be impossible to
|
|
||||||
// correctly infer which optional is provided or missing in general.
|
|
||||||
//
|
|
||||||
// Examples
|
|
||||||
//
|
|
||||||
// // Standard parser without descriptors
|
|
||||||
// specParser := NewParser(Minute | Hour | Dom | Month | Dow)
|
|
||||||
// sched, err := specParser.Parse("0 0 15 */3 *")
|
|
||||||
//
|
|
||||||
// // Same as above, just excludes time fields
|
|
||||||
// specParser := NewParser(Dom | Month | Dow)
|
|
||||||
// sched, err := specParser.Parse("15 */3 *")
|
|
||||||
//
|
|
||||||
// // Same as above, just makes Dow optional
|
|
||||||
// specParser := NewParser(Dom | Month | DowOptional)
|
|
||||||
// sched, err := specParser.Parse("15 */3")
|
|
||||||
func NewParser(options ParseOption) Parser {
|
|
||||||
optionals := 0
|
|
||||||
if options&DowOptional > 0 {
|
|
||||||
optionals++
|
|
||||||
}
|
|
||||||
if options&SecondOptional > 0 {
|
|
||||||
optionals++
|
|
||||||
}
|
|
||||||
if optionals > 1 {
|
|
||||||
panic("multiple optionals may not be configured")
|
|
||||||
}
|
|
||||||
return Parser{options}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse returns a new crontab schedule representing the given spec.
|
|
||||||
// It returns a descriptive error if the spec is not valid.
|
|
||||||
// It accepts crontab specs and features configured by NewParser.
|
|
||||||
func (p Parser) Parse(spec string) (Schedule, error) {
|
|
||||||
if len(spec) == 0 {
|
|
||||||
return nil, errors.New("empty spec string")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract timezone if present
|
|
||||||
var loc = time.Local
|
|
||||||
if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") {
|
|
||||||
var err error
|
|
||||||
i := strings.Index(spec, " ")
|
|
||||||
eq := strings.Index(spec, "=")
|
|
||||||
if loc, err = time.LoadLocation(spec[eq+1 : i]); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "provided bad location")
|
|
||||||
}
|
|
||||||
spec = strings.TrimSpace(spec[i:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle named schedules (descriptors), if configured
|
|
||||||
if strings.HasPrefix(spec, "@") {
|
|
||||||
if p.options&Descriptor == 0 {
|
|
||||||
return nil, errors.New("descriptors not enabled")
|
|
||||||
}
|
|
||||||
return parseDescriptor(spec, loc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split on whitespace.
|
|
||||||
fields := strings.Fields(spec)
|
|
||||||
|
|
||||||
// Validate & fill in any omitted or optional fields
|
|
||||||
var err error
|
|
||||||
fields, err = normalizeFields(fields, p.options)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
field := func(field string, r bounds) uint64 {
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var bits uint64
|
|
||||||
bits, err = getField(field, r)
|
|
||||||
return bits
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
second = field(fields[0], seconds)
|
|
||||||
minute = field(fields[1], minutes)
|
|
||||||
hour = field(fields[2], hours)
|
|
||||||
dayofmonth = field(fields[3], dom)
|
|
||||||
month = field(fields[4], months)
|
|
||||||
dayofweek = field(fields[5], dow)
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SpecSchedule{
|
|
||||||
Second: second,
|
|
||||||
Minute: minute,
|
|
||||||
Hour: hour,
|
|
||||||
Dom: dayofmonth,
|
|
||||||
Month: month,
|
|
||||||
Dow: dayofweek,
|
|
||||||
Location: loc,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeFields takes a subset set of the time fields and returns the full set
|
|
||||||
// with defaults (zeroes) populated for unset fields.
|
|
||||||
//
|
|
||||||
// As part of performing this function, it also validates that the provided
|
|
||||||
// fields are compatible with the configured options.
|
|
||||||
func normalizeFields(fields []string, options ParseOption) ([]string, error) {
|
|
||||||
// Validate optionals & add their field to options
|
|
||||||
optionals := 0
|
|
||||||
if options&SecondOptional > 0 {
|
|
||||||
options |= Second
|
|
||||||
optionals++
|
|
||||||
}
|
|
||||||
if options&DowOptional > 0 {
|
|
||||||
options |= Dow
|
|
||||||
optionals++
|
|
||||||
}
|
|
||||||
if optionals > 1 {
|
|
||||||
return nil, errors.New("multiple optionals may not be configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out how many fields we need
|
|
||||||
max := 0
|
|
||||||
for _, place := range places {
|
|
||||||
if options&place > 0 {
|
|
||||||
max++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
min := max - optionals
|
|
||||||
|
|
||||||
// Validate number of fields
|
|
||||||
if count := len(fields); count < min || count > max {
|
|
||||||
if min == max {
|
|
||||||
return nil, errors.New("incorrect number of fields")
|
|
||||||
}
|
|
||||||
return nil, errors.New("incorrect number of fields, expected " + strconv.Itoa(min) + "-" + strconv.Itoa(max))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate the optional field if not provided
|
|
||||||
if min < max && len(fields) == min {
|
|
||||||
switch {
|
|
||||||
case options&DowOptional > 0:
|
|
||||||
fields = append(fields, defaults[5]) // TODO: improve access to default
|
|
||||||
case options&SecondOptional > 0:
|
|
||||||
fields = append([]string{defaults[0]}, fields...)
|
|
||||||
default:
|
|
||||||
return nil, errors.New("unexpected optional field")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate all fields not part of options with their defaults
|
|
||||||
n := 0
|
|
||||||
expandedFields := make([]string, len(places))
|
|
||||||
copy(expandedFields, defaults)
|
|
||||||
for i, place := range places {
|
|
||||||
if options&place > 0 {
|
|
||||||
expandedFields[i] = fields[n]
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return expandedFields, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var standardParser = NewParser(
|
|
||||||
Minute | Hour | Dom | Month | Dow | Descriptor,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseStandard returns a new crontab schedule representing the given
|
|
||||||
// standardSpec (https://en.wikipedia.org/wiki/Cron). It requires 5 entries
|
|
||||||
// representing: minute, hour, day of month, month and day of week, in that
|
|
||||||
// order. It returns a descriptive error if the spec is not valid.
|
|
||||||
//
|
|
||||||
// It accepts
|
|
||||||
// - Standard crontab specs, e.g. "* * * * ?"
|
|
||||||
// - Descriptors, e.g. "@midnight", "@every 1h30m"
|
|
||||||
func ParseStandard(standardSpec string) (Schedule, error) {
|
|
||||||
return standardParser.Parse(standardSpec)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getField returns an Int with the bits set representing all of the times that
|
|
||||||
// the field represents or error parsing field value. A "field" is a comma-separated
|
|
||||||
// list of "ranges".
|
|
||||||
func getField(field string, r bounds) (uint64, error) {
|
|
||||||
var bits uint64
|
|
||||||
ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' })
|
|
||||||
for _, expr := range ranges {
|
|
||||||
bit, err := getRange(expr, r)
|
|
||||||
if err != nil {
|
|
||||||
return bits, err
|
|
||||||
}
|
|
||||||
bits |= bit
|
|
||||||
}
|
|
||||||
return bits, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRange returns the bits indicated by the given expression:
|
|
||||||
//
|
|
||||||
// number | number "-" number [ "/" number ]
|
|
||||||
//
|
|
||||||
// or error parsing range.
|
|
||||||
func getRange(expr string, r bounds) (uint64, error) {
|
|
||||||
var (
|
|
||||||
start, end, step uint
|
|
||||||
rangeAndStep = strings.Split(expr, "/")
|
|
||||||
lowAndHigh = strings.Split(rangeAndStep[0], "-")
|
|
||||||
singleDigit = len(lowAndHigh) == 1
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
var extra uint64
|
|
||||||
if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" {
|
|
||||||
start = r.min
|
|
||||||
end = r.max
|
|
||||||
extra = starBit
|
|
||||||
} else {
|
|
||||||
start, err = parseIntOrName(lowAndHigh[0], r.names)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
switch len(lowAndHigh) {
|
|
||||||
case 1:
|
|
||||||
end = start
|
|
||||||
case 2:
|
|
||||||
end, err = parseIntOrName(lowAndHigh[1], r.names)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 0, errors.New("too many hyphens: " + expr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch len(rangeAndStep) {
|
|
||||||
case 1:
|
|
||||||
step = 1
|
|
||||||
case 2:
|
|
||||||
step, err = mustParseInt(rangeAndStep[1])
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling: "N/step" means "N-max/step".
|
|
||||||
if singleDigit {
|
|
||||||
end = r.max
|
|
||||||
}
|
|
||||||
if step > 1 {
|
|
||||||
extra = 0
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 0, errors.New("too many slashes: " + expr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if start < r.min {
|
|
||||||
return 0, errors.New("beginning of range below minimum: " + expr)
|
|
||||||
}
|
|
||||||
if end > r.max {
|
|
||||||
return 0, errors.New("end of range above maximum: " + expr)
|
|
||||||
}
|
|
||||||
if start > end {
|
|
||||||
return 0, errors.New("beginning of range after end: " + expr)
|
|
||||||
}
|
|
||||||
if step == 0 {
|
|
||||||
return 0, errors.New("step cannot be zero: " + expr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return getBits(start, end, step) | extra, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseIntOrName returns the (possibly-named) integer contained in expr.
|
|
||||||
func parseIntOrName(expr string, names map[string]uint) (uint, error) {
|
|
||||||
if names != nil {
|
|
||||||
if namedInt, ok := names[strings.ToLower(expr)]; ok {
|
|
||||||
return namedInt, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mustParseInt(expr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustParseInt parses the given expression as an int or returns an error.
|
|
||||||
func mustParseInt(expr string) (uint, error) {
|
|
||||||
num, err := strconv.Atoi(expr)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.Wrap(err, "failed to parse number")
|
|
||||||
}
|
|
||||||
if num < 0 {
|
|
||||||
return 0, errors.New("number must be positive")
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint(num), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBits sets all bits in the range [min, max], modulo the given step size.
|
|
||||||
func getBits(min, max, step uint) uint64 {
|
|
||||||
var bits uint64
|
|
||||||
|
|
||||||
// If step is 1, use shifts.
|
|
||||||
if step == 1 {
|
|
||||||
return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Else, use a simple loop.
|
|
||||||
for i := min; i <= max; i += step {
|
|
||||||
bits |= 1 << i
|
|
||||||
}
|
|
||||||
return bits
|
|
||||||
}
|
|
||||||
|
|
||||||
// all returns all bits within the given bounds.
|
|
||||||
func all(r bounds) uint64 {
|
|
||||||
return getBits(r.min, r.max, 1) | starBit
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDescriptor returns a predefined schedule for the expression, or error if none matches.
|
|
||||||
func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
|
|
||||||
switch descriptor {
|
|
||||||
case "@yearly", "@annually":
|
|
||||||
return &SpecSchedule{
|
|
||||||
Second: 1 << seconds.min,
|
|
||||||
Minute: 1 << minutes.min,
|
|
||||||
Hour: 1 << hours.min,
|
|
||||||
Dom: 1 << dom.min,
|
|
||||||
Month: 1 << months.min,
|
|
||||||
Dow: all(dow),
|
|
||||||
Location: loc,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
case "@monthly":
|
|
||||||
return &SpecSchedule{
|
|
||||||
Second: 1 << seconds.min,
|
|
||||||
Minute: 1 << minutes.min,
|
|
||||||
Hour: 1 << hours.min,
|
|
||||||
Dom: 1 << dom.min,
|
|
||||||
Month: all(months),
|
|
||||||
Dow: all(dow),
|
|
||||||
Location: loc,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
case "@weekly":
|
|
||||||
return &SpecSchedule{
|
|
||||||
Second: 1 << seconds.min,
|
|
||||||
Minute: 1 << minutes.min,
|
|
||||||
Hour: 1 << hours.min,
|
|
||||||
Dom: all(dom),
|
|
||||||
Month: all(months),
|
|
||||||
Dow: 1 << dow.min,
|
|
||||||
Location: loc,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
case "@daily", "@midnight":
|
|
||||||
return &SpecSchedule{
|
|
||||||
Second: 1 << seconds.min,
|
|
||||||
Minute: 1 << minutes.min,
|
|
||||||
Hour: 1 << hours.min,
|
|
||||||
Dom: all(dom),
|
|
||||||
Month: all(months),
|
|
||||||
Dow: all(dow),
|
|
||||||
Location: loc,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
case "@hourly":
|
|
||||||
return &SpecSchedule{
|
|
||||||
Second: 1 << seconds.min,
|
|
||||||
Minute: 1 << minutes.min,
|
|
||||||
Hour: all(hours),
|
|
||||||
Dom: all(dom),
|
|
||||||
Month: all(months),
|
|
||||||
Dow: all(dow),
|
|
||||||
Location: loc,
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
// Continue to check @every prefix below
|
|
||||||
}
|
|
||||||
|
|
||||||
const every = "@every "
|
|
||||||
if strings.HasPrefix(descriptor, every) {
|
|
||||||
duration, err := time.ParseDuration(descriptor[len(every):])
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to parse duration")
|
|
||||||
}
|
|
||||||
return Every(duration), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.New("unrecognized descriptor: " + descriptor)
|
|
||||||
}
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
//nolint:all
|
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var secondParser = NewParser(Second | Minute | Hour | Dom | Month | DowOptional | Descriptor)
|
|
||||||
|
|
||||||
func TestRange(t *testing.T) {
|
|
||||||
zero := uint64(0)
|
|
||||||
ranges := []struct {
|
|
||||||
expr string
|
|
||||||
min, max uint
|
|
||||||
expected uint64
|
|
||||||
err string
|
|
||||||
}{
|
|
||||||
{"5", 0, 7, 1 << 5, ""},
|
|
||||||
{"0", 0, 7, 1 << 0, ""},
|
|
||||||
{"7", 0, 7, 1 << 7, ""},
|
|
||||||
|
|
||||||
{"5-5", 0, 7, 1 << 5, ""},
|
|
||||||
{"5-6", 0, 7, 1<<5 | 1<<6, ""},
|
|
||||||
{"5-7", 0, 7, 1<<5 | 1<<6 | 1<<7, ""},
|
|
||||||
|
|
||||||
{"5-6/2", 0, 7, 1 << 5, ""},
|
|
||||||
{"5-7/2", 0, 7, 1<<5 | 1<<7, ""},
|
|
||||||
{"5-7/1", 0, 7, 1<<5 | 1<<6 | 1<<7, ""},
|
|
||||||
|
|
||||||
{"*", 1, 3, 1<<1 | 1<<2 | 1<<3 | starBit, ""},
|
|
||||||
{"*/2", 1, 3, 1<<1 | 1<<3, ""},
|
|
||||||
|
|
||||||
{"5--5", 0, 0, zero, "too many hyphens"},
|
|
||||||
{"jan-x", 0, 0, zero, `failed to parse number: strconv.Atoi: parsing "jan": invalid syntax`},
|
|
||||||
{"2-x", 1, 5, zero, `failed to parse number: strconv.Atoi: parsing "x": invalid syntax`},
|
|
||||||
{"*/-12", 0, 0, zero, "number must be positive"},
|
|
||||||
{"*//2", 0, 0, zero, "too many slashes"},
|
|
||||||
{"1", 3, 5, zero, "below minimum"},
|
|
||||||
{"6", 3, 5, zero, "above maximum"},
|
|
||||||
{"5-3", 3, 5, zero, "beginning of range after end: 5-3"},
|
|
||||||
{"*/0", 0, 0, zero, "step cannot be zero: */0"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range ranges {
|
|
||||||
actual, err := getRange(c.expr, bounds{c.min, c.max, nil})
|
|
||||||
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
|
|
||||||
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
|
|
||||||
}
|
|
||||||
if len(c.err) == 0 && err != nil {
|
|
||||||
t.Errorf("%s => unexpected error %v", c.expr, err)
|
|
||||||
}
|
|
||||||
if actual != c.expected {
|
|
||||||
t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestField(t *testing.T) {
|
|
||||||
fields := []struct {
|
|
||||||
expr string
|
|
||||||
min, max uint
|
|
||||||
expected uint64
|
|
||||||
}{
|
|
||||||
{"5", 1, 7, 1 << 5},
|
|
||||||
{"5,6", 1, 7, 1<<5 | 1<<6},
|
|
||||||
{"5,6,7", 1, 7, 1<<5 | 1<<6 | 1<<7},
|
|
||||||
{"1,5-7/2,3", 1, 7, 1<<1 | 1<<5 | 1<<7 | 1<<3},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range fields {
|
|
||||||
actual, _ := getField(c.expr, bounds{c.min, c.max, nil})
|
|
||||||
if actual != c.expected {
|
|
||||||
t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAll(t *testing.T) {
|
|
||||||
allBits := []struct {
|
|
||||||
r bounds
|
|
||||||
expected uint64
|
|
||||||
}{
|
|
||||||
{minutes, 0xfffffffffffffff}, // 0-59: 60 ones
|
|
||||||
{hours, 0xffffff}, // 0-23: 24 ones
|
|
||||||
{dom, 0xfffffffe}, // 1-31: 31 ones, 1 zero
|
|
||||||
{months, 0x1ffe}, // 1-12: 12 ones, 1 zero
|
|
||||||
{dow, 0x7f}, // 0-6: 7 ones
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range allBits {
|
|
||||||
actual := all(c.r) // all() adds the starBit, so compensate for that..
|
|
||||||
if c.expected|starBit != actual {
|
|
||||||
t.Errorf("%d-%d/%d => expected %b, got %b",
|
|
||||||
c.r.min, c.r.max, 1, c.expected|starBit, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBits(t *testing.T) {
|
|
||||||
bits := []struct {
|
|
||||||
min, max, step uint
|
|
||||||
expected uint64
|
|
||||||
}{
|
|
||||||
{0, 0, 1, 0x1},
|
|
||||||
{1, 1, 1, 0x2},
|
|
||||||
{1, 5, 2, 0x2a}, // 101010
|
|
||||||
{1, 4, 2, 0xa}, // 1010
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range bits {
|
|
||||||
actual := getBits(c.min, c.max, c.step)
|
|
||||||
if c.expected != actual {
|
|
||||||
t.Errorf("%d-%d/%d => expected %b, got %b",
|
|
||||||
c.min, c.max, c.step, c.expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseScheduleErrors(t *testing.T) {
|
|
||||||
var tests = []struct{ expr, err string }{
|
|
||||||
{"* 5 j * * *", `failed to parse number: strconv.Atoi: parsing "j": invalid syntax`},
|
|
||||||
{"@every Xm", "failed to parse duration"},
|
|
||||||
{"@unrecognized", "unrecognized descriptor"},
|
|
||||||
{"* * * *", "incorrect number of fields, expected 5-6"},
|
|
||||||
{"", "empty spec string"},
|
|
||||||
}
|
|
||||||
for _, c := range tests {
|
|
||||||
actual, err := secondParser.Parse(c.expr)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), c.err) {
|
|
||||||
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
|
|
||||||
}
|
|
||||||
if actual != nil {
|
|
||||||
t.Errorf("expected nil schedule on error, got %v", actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSchedule(t *testing.T) {
|
|
||||||
tokyo, _ := time.LoadLocation("Asia/Tokyo")
|
|
||||||
entries := []struct {
|
|
||||||
parser Parser
|
|
||||||
expr string
|
|
||||||
expected Schedule
|
|
||||||
}{
|
|
||||||
{secondParser, "0 5 * * * *", every5min(time.Local)},
|
|
||||||
{standardParser, "5 * * * *", every5min(time.Local)},
|
|
||||||
{secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)},
|
|
||||||
{standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)},
|
|
||||||
{secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)},
|
|
||||||
{secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}},
|
|
||||||
{secondParser, "@midnight", midnight(time.Local)},
|
|
||||||
{secondParser, "TZ=UTC @midnight", midnight(time.UTC)},
|
|
||||||
{secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)},
|
|
||||||
{secondParser, "@yearly", annual(time.Local)},
|
|
||||||
{secondParser, "@annually", annual(time.Local)},
|
|
||||||
{
|
|
||||||
parser: secondParser,
|
|
||||||
expr: "* 5 * * * *",
|
|
||||||
expected: &SpecSchedule{
|
|
||||||
Second: all(seconds),
|
|
||||||
Minute: 1 << 5,
|
|
||||||
Hour: all(hours),
|
|
||||||
Dom: all(dom),
|
|
||||||
Month: all(months),
|
|
||||||
Dow: all(dow),
|
|
||||||
Location: time.Local,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range entries {
|
|
||||||
actual, err := c.parser.Parse(c.expr)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s => unexpected error %v", c.expr, err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(actual, c.expected) {
|
|
||||||
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOptionalSecondSchedule(t *testing.T) {
|
|
||||||
parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor)
|
|
||||||
entries := []struct {
|
|
||||||
expr string
|
|
||||||
expected Schedule
|
|
||||||
}{
|
|
||||||
{"0 5 * * * *", every5min(time.Local)},
|
|
||||||
{"5 5 * * * *", every5min5s(time.Local)},
|
|
||||||
{"5 * * * *", every5min(time.Local)},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range entries {
|
|
||||||
actual, err := parser.Parse(c.expr)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s => unexpected error %v", c.expr, err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(actual, c.expected) {
|
|
||||||
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeFields(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input []string
|
|
||||||
options ParseOption
|
|
||||||
expected []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"AllFields_NoOptional",
|
|
||||||
[]string{"0", "5", "*", "*", "*", "*"},
|
|
||||||
Second | Minute | Hour | Dom | Month | Dow | Descriptor,
|
|
||||||
[]string{"0", "5", "*", "*", "*", "*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"AllFields_SecondOptional_Provided",
|
|
||||||
[]string{"0", "5", "*", "*", "*", "*"},
|
|
||||||
SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor,
|
|
||||||
[]string{"0", "5", "*", "*", "*", "*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"AllFields_SecondOptional_NotProvided",
|
|
||||||
[]string{"5", "*", "*", "*", "*"},
|
|
||||||
SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor,
|
|
||||||
[]string{"0", "5", "*", "*", "*", "*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SubsetFields_NoOptional",
|
|
||||||
[]string{"5", "15", "*"},
|
|
||||||
Hour | Dom | Month,
|
|
||||||
[]string{"0", "0", "5", "15", "*", "*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SubsetFields_DowOptional_Provided",
|
|
||||||
[]string{"5", "15", "*", "4"},
|
|
||||||
Hour | Dom | Month | DowOptional,
|
|
||||||
[]string{"0", "0", "5", "15", "*", "4"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SubsetFields_DowOptional_NotProvided",
|
|
||||||
[]string{"5", "15", "*"},
|
|
||||||
Hour | Dom | Month | DowOptional,
|
|
||||||
[]string{"0", "0", "5", "15", "*", "*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SubsetFields_SecondOptional_NotProvided",
|
|
||||||
[]string{"5", "15", "*"},
|
|
||||||
SecondOptional | Hour | Dom | Month,
|
|
||||||
[]string{"0", "0", "5", "15", "*", "*"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(*testing.T) {
|
|
||||||
actual, err := normalizeFields(test.input, test.options)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(actual, test.expected) {
|
|
||||||
t.Errorf("expected %v, got %v", test.expected, actual)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeFields_Errors(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input []string
|
|
||||||
options ParseOption
|
|
||||||
err string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"TwoOptionals",
|
|
||||||
[]string{"0", "5", "*", "*", "*", "*"},
|
|
||||||
SecondOptional | Minute | Hour | Dom | Month | DowOptional,
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"TooManyFields",
|
|
||||||
[]string{"0", "5", "*", "*"},
|
|
||||||
SecondOptional | Minute | Hour,
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NoFields",
|
|
||||||
[]string{},
|
|
||||||
SecondOptional | Minute | Hour,
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"TooFewFields",
|
|
||||||
[]string{"*"},
|
|
||||||
SecondOptional | Minute | Hour,
|
|
||||||
"",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(*testing.T) {
|
|
||||||
actual, err := normalizeFields(test.input, test.options)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("expected an error, got none. results: %v", actual)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), test.err) {
|
|
||||||
t.Errorf("expected error %q, got %q", test.err, err.Error())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStandardSpecSchedule(t *testing.T) {
|
|
||||||
entries := []struct {
|
|
||||||
expr string
|
|
||||||
expected Schedule
|
|
||||||
err string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
expr: "5 * * * *",
|
|
||||||
expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expr: "@every 5m",
|
|
||||||
expected: ConstantDelaySchedule{time.Duration(5) * time.Minute},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expr: "5 j * * *",
|
|
||||||
err: `failed to parse number: strconv.Atoi: parsing "j": invalid syntax`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expr: "* * * *",
|
|
||||||
err: "incorrect number of fields",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range entries {
|
|
||||||
actual, err := ParseStandard(c.expr)
|
|
||||||
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
|
|
||||||
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
|
|
||||||
}
|
|
||||||
if len(c.err) == 0 && err != nil {
|
|
||||||
t.Errorf("%s => unexpected error %v", c.expr, err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(actual, c.expected) {
|
|
||||||
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoDescriptorParser(t *testing.T) {
|
|
||||||
parser := NewParser(Minute | Hour)
|
|
||||||
_, err := parser.Parse("@every 1m")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected an error, got none")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func every5min(loc *time.Location) *SpecSchedule {
|
|
||||||
return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
|
|
||||||
}
|
|
||||||
|
|
||||||
func every5min5s(loc *time.Location) *SpecSchedule {
|
|
||||||
return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
|
|
||||||
}
|
|
||||||
|
|
||||||
func midnight(loc *time.Location) *SpecSchedule {
|
|
||||||
return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc}
|
|
||||||
}
|
|
||||||
|
|
||||||
func annual(loc *time.Location) *SpecSchedule {
|
|
||||||
return &SpecSchedule{
|
|
||||||
Second: 1 << seconds.min,
|
|
||||||
Minute: 1 << minutes.min,
|
|
||||||
Hour: 1 << hours.min,
|
|
||||||
Dom: 1 << dom.min,
|
|
||||||
Month: 1 << months.min,
|
|
||||||
Dow: all(dow),
|
|
||||||
Location: loc,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// SpecSchedule specifies a duty cycle (to the second granularity), based on a
|
|
||||||
// traditional crontab specification. It is computed initially and stored as bit sets.
|
|
||||||
type SpecSchedule struct {
|
|
||||||
Second, Minute, Hour, Dom, Month, Dow uint64
|
|
||||||
|
|
||||||
// Override location for this schedule.
|
|
||||||
Location *time.Location
|
|
||||||
}
|
|
||||||
|
|
||||||
// bounds provides a range of acceptable values (plus a map of name to value).
|
|
||||||
type bounds struct {
|
|
||||||
min, max uint
|
|
||||||
names map[string]uint
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bounds for each field.
|
|
||||||
var (
|
|
||||||
seconds = bounds{0, 59, nil}
|
|
||||||
minutes = bounds{0, 59, nil}
|
|
||||||
hours = bounds{0, 23, nil}
|
|
||||||
dom = bounds{1, 31, nil}
|
|
||||||
months = bounds{1, 12, map[string]uint{
|
|
||||||
"jan": 1,
|
|
||||||
"feb": 2,
|
|
||||||
"mar": 3,
|
|
||||||
"apr": 4,
|
|
||||||
"may": 5,
|
|
||||||
"jun": 6,
|
|
||||||
"jul": 7,
|
|
||||||
"aug": 8,
|
|
||||||
"sep": 9,
|
|
||||||
"oct": 10,
|
|
||||||
"nov": 11,
|
|
||||||
"dec": 12,
|
|
||||||
}}
|
|
||||||
dow = bounds{0, 6, map[string]uint{
|
|
||||||
"sun": 0,
|
|
||||||
"mon": 1,
|
|
||||||
"tue": 2,
|
|
||||||
"wed": 3,
|
|
||||||
"thu": 4,
|
|
||||||
"fri": 5,
|
|
||||||
"sat": 6,
|
|
||||||
}}
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Set the top bit if a star was included in the expression.
|
|
||||||
starBit = 1 << 63
|
|
||||||
)
|
|
||||||
|
|
||||||
// Next returns the next time this schedule is activated, greater than the given
|
|
||||||
// time. If no time can be found to satisfy the schedule, return the zero time.
|
|
||||||
func (s *SpecSchedule) Next(t time.Time) time.Time {
|
|
||||||
// General approach
|
|
||||||
//
|
|
||||||
// For Month, Day, Hour, Minute, Second:
|
|
||||||
// Check if the time value matches. If yes, continue to the next field.
|
|
||||||
// If the field doesn't match the schedule, then increment the field until it matches.
|
|
||||||
// While incrementing the field, a wrap-around brings it back to the beginning
|
|
||||||
// of the field list (since it is necessary to re-verify previous field
|
|
||||||
// values)
|
|
||||||
|
|
||||||
// Convert the given time into the schedule's timezone, if one is specified.
|
|
||||||
// Save the original timezone so we can convert back after we find a time.
|
|
||||||
// Note that schedules without a time zone specified (time.Local) are treated
|
|
||||||
// as local to the time provided.
|
|
||||||
origLocation := t.Location()
|
|
||||||
loc := s.Location
|
|
||||||
if loc == time.Local {
|
|
||||||
loc = t.Location()
|
|
||||||
}
|
|
||||||
if s.Location != time.Local {
|
|
||||||
t = t.In(s.Location)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start at the earliest possible time (the upcoming second).
|
|
||||||
t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)
|
|
||||||
|
|
||||||
// This flag indicates whether a field has been incremented.
|
|
||||||
added := false
|
|
||||||
|
|
||||||
// If no time is found within five years, return zero.
|
|
||||||
yearLimit := t.Year() + 5
|
|
||||||
|
|
||||||
WRAP:
|
|
||||||
if t.Year() > yearLimit {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first applicable month.
|
|
||||||
// If it's this month, then do nothing.
|
|
||||||
for 1<<uint(t.Month())&s.Month == 0 {
|
|
||||||
// If we have to add a month, reset the other parts to 0.
|
|
||||||
if !added {
|
|
||||||
added = true
|
|
||||||
// Otherwise, set the date at the beginning (since the current time is irrelevant).
|
|
||||||
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, loc)
|
|
||||||
}
|
|
||||||
t = t.AddDate(0, 1, 0)
|
|
||||||
|
|
||||||
// Wrapped around.
|
|
||||||
if t.Month() == time.January {
|
|
||||||
goto WRAP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now get a day in that month.
|
|
||||||
//
|
|
||||||
// NOTE: This causes issues for daylight savings regimes where midnight does
|
|
||||||
// not exist. For example: Sao Paulo has DST that transforms midnight on
|
|
||||||
// 11/3 into 1am. Handle that by noticing when the Hour ends up != 0.
|
|
||||||
for !dayMatches(s, t) {
|
|
||||||
if !added {
|
|
||||||
added = true
|
|
||||||
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
|
||||||
}
|
|
||||||
t = t.AddDate(0, 0, 1)
|
|
||||||
// Notice if the hour is no longer midnight due to DST.
|
|
||||||
// Add an hour if it's 23, subtract an hour if it's 1.
|
|
||||||
if t.Hour() != 0 {
|
|
||||||
if t.Hour() > 12 {
|
|
||||||
t = t.Add(time.Duration(24-t.Hour()) * time.Hour)
|
|
||||||
} else {
|
|
||||||
t = t.Add(time.Duration(-t.Hour()) * time.Hour)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.Day() == 1 {
|
|
||||||
goto WRAP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for 1<<uint(t.Hour())&s.Hour == 0 {
|
|
||||||
if !added {
|
|
||||||
added = true
|
|
||||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, loc)
|
|
||||||
}
|
|
||||||
t = t.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
if t.Hour() == 0 {
|
|
||||||
goto WRAP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for 1<<uint(t.Minute())&s.Minute == 0 {
|
|
||||||
if !added {
|
|
||||||
added = true
|
|
||||||
t = t.Truncate(time.Minute)
|
|
||||||
}
|
|
||||||
t = t.Add(1 * time.Minute)
|
|
||||||
|
|
||||||
if t.Minute() == 0 {
|
|
||||||
goto WRAP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for 1<<uint(t.Second())&s.Second == 0 {
|
|
||||||
if !added {
|
|
||||||
added = true
|
|
||||||
t = t.Truncate(time.Second)
|
|
||||||
}
|
|
||||||
t = t.Add(1 * time.Second)
|
|
||||||
|
|
||||||
if t.Second() == 0 {
|
|
||||||
goto WRAP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.In(origLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
// dayMatches returns true if the schedule's day-of-week and day-of-month
|
|
||||||
// restrictions are satisfied by the given time.
|
|
||||||
func dayMatches(s *SpecSchedule, t time.Time) bool {
|
|
||||||
var (
|
|
||||||
domMatch = 1<<uint(t.Day())&s.Dom > 0
|
|
||||||
dowMatch = 1<<uint(t.Weekday())&s.Dow > 0
|
|
||||||
)
|
|
||||||
if s.Dom&starBit > 0 || s.Dow&starBit > 0 {
|
|
||||||
return domMatch && dowMatch
|
|
||||||
}
|
|
||||||
return domMatch || dowMatch
|
|
||||||
}
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
//nolint:all
|
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestActivation(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
time, spec string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
// Every fifteen minutes.
|
|
||||||
{"Mon Jul 9 15:00 2012", "0/15 * * * *", true},
|
|
||||||
{"Mon Jul 9 15:45 2012", "0/15 * * * *", true},
|
|
||||||
{"Mon Jul 9 15:40 2012", "0/15 * * * *", false},
|
|
||||||
|
|
||||||
// Every fifteen minutes, starting at 5 minutes.
|
|
||||||
{"Mon Jul 9 15:05 2012", "5/15 * * * *", true},
|
|
||||||
{"Mon Jul 9 15:20 2012", "5/15 * * * *", true},
|
|
||||||
{"Mon Jul 9 15:50 2012", "5/15 * * * *", true},
|
|
||||||
|
|
||||||
// Named months
|
|
||||||
{"Sun Jul 15 15:00 2012", "0/15 * * Jul *", true},
|
|
||||||
{"Sun Jul 15 15:00 2012", "0/15 * * Jun *", false},
|
|
||||||
|
|
||||||
// Everything set.
|
|
||||||
{"Sun Jul 15 08:30 2012", "30 08 ? Jul Sun", true},
|
|
||||||
{"Sun Jul 15 08:30 2012", "30 08 15 Jul ?", true},
|
|
||||||
{"Mon Jul 16 08:30 2012", "30 08 ? Jul Sun", false},
|
|
||||||
{"Mon Jul 16 08:30 2012", "30 08 15 Jul ?", false},
|
|
||||||
|
|
||||||
// Predefined schedules
|
|
||||||
{"Mon Jul 9 15:00 2012", "@hourly", true},
|
|
||||||
{"Mon Jul 9 15:04 2012", "@hourly", false},
|
|
||||||
{"Mon Jul 9 15:00 2012", "@daily", false},
|
|
||||||
{"Mon Jul 9 00:00 2012", "@daily", true},
|
|
||||||
{"Mon Jul 9 00:00 2012", "@weekly", false},
|
|
||||||
{"Sun Jul 8 00:00 2012", "@weekly", true},
|
|
||||||
{"Sun Jul 8 01:00 2012", "@weekly", false},
|
|
||||||
{"Sun Jul 8 00:00 2012", "@monthly", false},
|
|
||||||
{"Sun Jul 1 00:00 2012", "@monthly", true},
|
|
||||||
|
|
||||||
// Test interaction of DOW and DOM.
|
|
||||||
// If both are restricted, then only one needs to match.
|
|
||||||
{"Sun Jul 15 00:00 2012", "* * 1,15 * Sun", true},
|
|
||||||
{"Fri Jun 15 00:00 2012", "* * 1,15 * Sun", true},
|
|
||||||
{"Wed Aug 1 00:00 2012", "* * 1,15 * Sun", true},
|
|
||||||
{"Sun Jul 15 00:00 2012", "* * */10 * Sun", true}, // verifies #70
|
|
||||||
|
|
||||||
// However, if one has a star, then both need to match.
|
|
||||||
{"Sun Jul 15 00:00 2012", "* * * * Mon", false},
|
|
||||||
{"Mon Jul 9 00:00 2012", "* * 1,15 * *", false},
|
|
||||||
{"Sun Jul 15 00:00 2012", "* * 1,15 * *", true},
|
|
||||||
{"Sun Jul 15 00:00 2012", "* * */2 * Sun", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
sched, err := ParseStandard(test.spec)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
actual := sched.Next(getTime(test.time).Add(-1 * time.Second))
|
|
||||||
expected := getTime(test.time)
|
|
||||||
if test.expected && expected != actual || !test.expected && expected == actual {
|
|
||||||
t.Errorf("Fail evaluating %s on %s: (expected) %s != %s (actual)",
|
|
||||||
test.spec, test.time, expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNext(t *testing.T) {
|
|
||||||
runs := []struct {
|
|
||||||
time, spec string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
// Simple cases
|
|
||||||
{"Mon Jul 9 14:45 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
|
|
||||||
{"Mon Jul 9 14:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
|
|
||||||
{"Mon Jul 9 14:59:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
|
|
||||||
|
|
||||||
// Wrap around hours
|
|
||||||
{"Mon Jul 9 15:45 2012", "0 20-35/15 * * * *", "Mon Jul 9 16:20 2012"},
|
|
||||||
|
|
||||||
// Wrap around days
|
|
||||||
{"Mon Jul 9 23:46 2012", "0 */15 * * * *", "Tue Jul 10 00:00 2012"},
|
|
||||||
{"Mon Jul 9 23:45 2012", "0 20-35/15 * * * *", "Tue Jul 10 00:20 2012"},
|
|
||||||
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * * * *", "Tue Jul 10 00:20:15 2012"},
|
|
||||||
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 * * *", "Tue Jul 10 01:20:15 2012"},
|
|
||||||
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 10-12 * * *", "Tue Jul 10 10:20:15 2012"},
|
|
||||||
|
|
||||||
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 */2 * *", "Thu Jul 11 01:20:15 2012"},
|
|
||||||
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 * *", "Wed Jul 10 00:20:15 2012"},
|
|
||||||
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 Jul *", "Wed Jul 10 00:20:15 2012"},
|
|
||||||
|
|
||||||
// Wrap around months
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 9 Apr-Oct ?", "Thu Aug 9 00:00 2012"},
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 */5 Apr,Aug,Oct Mon", "Tue Aug 1 00:00 2012"},
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 */5 Oct Mon", "Mon Oct 1 00:00 2012"},
|
|
||||||
|
|
||||||
// Wrap around years
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon", "Mon Feb 4 00:00 2013"},
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon/2", "Fri Feb 1 00:00 2013"},
|
|
||||||
|
|
||||||
// Wrap around minute, hour, day, month, and year
|
|
||||||
{"Mon Dec 31 23:59:45 2012", "0 * * * * *", "Tue Jan 1 00:00:00 2013"},
|
|
||||||
|
|
||||||
// Leap year
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 29 Feb ?", "Mon Feb 29 00:00 2016"},
|
|
||||||
|
|
||||||
// Daylight savings time 2am EST (-5) -> 3am EDT (-4)
|
|
||||||
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 30 2 11 Mar ?", "2013-03-11T02:30:00-0400"},
|
|
||||||
|
|
||||||
// hourly job
|
|
||||||
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T01:00:00-0500"},
|
|
||||||
{"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T03:00:00-0400"},
|
|
||||||
{"2012-03-11T03:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T04:00:00-0400"},
|
|
||||||
{"2012-03-11T04:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T05:00:00-0400"},
|
|
||||||
|
|
||||||
// hourly job using CRON_TZ
|
|
||||||
{"2012-03-11T00:00:00-0500", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T01:00:00-0500"},
|
|
||||||
{"2012-03-11T01:00:00-0500", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T03:00:00-0400"},
|
|
||||||
{"2012-03-11T03:00:00-0400", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T04:00:00-0400"},
|
|
||||||
{"2012-03-11T04:00:00-0400", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T05:00:00-0400"},
|
|
||||||
|
|
||||||
// 1am nightly job
|
|
||||||
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-11T01:00:00-0500"},
|
|
||||||
{"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-12T01:00:00-0400"},
|
|
||||||
|
|
||||||
// 2am nightly job (skipped)
|
|
||||||
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-03-12T02:00:00-0400"},
|
|
||||||
|
|
||||||
// Daylight savings time 2am EDT (-4) => 1am EST (-5)
|
|
||||||
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 30 2 04 Nov ?", "2012-11-04T02:30:00-0500"},
|
|
||||||
{"2012-11-04T01:45:00-0400", "TZ=America/New_York 0 30 1 04 Nov ?", "2012-11-04T01:30:00-0500"},
|
|
||||||
|
|
||||||
// hourly job
|
|
||||||
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0400"},
|
|
||||||
{"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0500"},
|
|
||||||
{"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T02:00:00-0500"},
|
|
||||||
|
|
||||||
// 1am nightly job (runs twice)
|
|
||||||
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0400"},
|
|
||||||
{"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0500"},
|
|
||||||
{"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-11-05T01:00:00-0500"},
|
|
||||||
|
|
||||||
// 2am nightly job
|
|
||||||
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 2 * * ?", "2012-11-04T02:00:00-0500"},
|
|
||||||
{"2012-11-04T02:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-11-05T02:00:00-0500"},
|
|
||||||
|
|
||||||
// 3am nightly job
|
|
||||||
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 3 * * ?", "2012-11-04T03:00:00-0500"},
|
|
||||||
{"2012-11-04T03:00:00-0500", "TZ=America/New_York 0 0 3 * * ?", "2012-11-05T03:00:00-0500"},
|
|
||||||
|
|
||||||
// hourly job
|
|
||||||
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0400"},
|
|
||||||
{"TZ=America/New_York 2012-11-04T01:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0500"},
|
|
||||||
{"TZ=America/New_York 2012-11-04T01:00:00-0500", "0 0 * * * ?", "2012-11-04T02:00:00-0500"},
|
|
||||||
|
|
||||||
// 1am nightly job (runs twice)
|
|
||||||
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0400"},
|
|
||||||
{"TZ=America/New_York 2012-11-04T01:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0500"},
|
|
||||||
{"TZ=America/New_York 2012-11-04T01:00:00-0500", "0 0 1 * * ?", "2012-11-05T01:00:00-0500"},
|
|
||||||
|
|
||||||
// 2am nightly job
|
|
||||||
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 2 * * ?", "2012-11-04T02:00:00-0500"},
|
|
||||||
{"TZ=America/New_York 2012-11-04T02:00:00-0500", "0 0 2 * * ?", "2012-11-05T02:00:00-0500"},
|
|
||||||
|
|
||||||
// 3am nightly job
|
|
||||||
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 3 * * ?", "2012-11-04T03:00:00-0500"},
|
|
||||||
{"TZ=America/New_York 2012-11-04T03:00:00-0500", "0 0 3 * * ?", "2012-11-05T03:00:00-0500"},
|
|
||||||
|
|
||||||
// Unsatisfiable
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 30 Feb ?", ""},
|
|
||||||
{"Mon Jul 9 23:35 2012", "0 0 0 31 Apr ?", ""},
|
|
||||||
|
|
||||||
// Monthly job
|
|
||||||
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 3 3 * ?", "2012-12-03T03:00:00-0500"},
|
|
||||||
|
|
||||||
// Test the scenario of DST resulting in midnight not being a valid time.
|
|
||||||
// https://github.com/robfig/cron/issues/157
|
|
||||||
{"2018-10-17T05:00:00-0400", "TZ=America/Sao_Paulo 0 0 9 10 * ?", "2018-11-10T06:00:00-0500"},
|
|
||||||
{"2018-02-14T05:00:00-0500", "TZ=America/Sao_Paulo 0 0 9 22 * ?", "2018-02-22T07:00:00-0500"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range runs {
|
|
||||||
sched, err := secondParser.Parse(c.spec)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
actual := sched.Next(getTime(c.time))
|
|
||||||
expected := getTime(c.expected)
|
|
||||||
if !actual.Equal(expected) {
|
|
||||||
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrors(t *testing.T) {
|
|
||||||
invalidSpecs := []string{
|
|
||||||
"xyz",
|
|
||||||
"60 0 * * *",
|
|
||||||
"0 60 * * *",
|
|
||||||
"0 0 * * XYZ",
|
|
||||||
}
|
|
||||||
for _, spec := range invalidSpecs {
|
|
||||||
_, err := ParseStandard(spec)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected an error parsing: ", spec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTime(value string) time.Time {
|
|
||||||
if value == "" {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var location = time.Local
|
|
||||||
if strings.HasPrefix(value, "TZ=") {
|
|
||||||
parts := strings.Fields(value)
|
|
||||||
loc, err := time.LoadLocation(parts[0][len("TZ="):])
|
|
||||||
if err != nil {
|
|
||||||
panic("could not parse location:" + err.Error())
|
|
||||||
}
|
|
||||||
location = loc
|
|
||||||
value = parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
var layouts = []string{
|
|
||||||
"Mon Jan 2 15:04 2006",
|
|
||||||
"Mon Jan 2 15:04:05 2006",
|
|
||||||
}
|
|
||||||
for _, layout := range layouts {
|
|
||||||
if t, err := time.ParseInLocation(layout, value, location); err == nil {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02T15:04:05-0700", value, location); err == nil {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
panic("could not parse time value " + value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNextWithTz(t *testing.T) {
|
|
||||||
runs := []struct {
|
|
||||||
time, spec string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
// Failing tests
|
|
||||||
{"2016-01-03T13:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"},
|
|
||||||
{"2016-01-03T04:09:03+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"},
|
|
||||||
|
|
||||||
// Passing tests
|
|
||||||
{"2016-01-03T14:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"},
|
|
||||||
{"2016-01-03T14:00:00+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"},
|
|
||||||
}
|
|
||||||
for _, c := range runs {
|
|
||||||
sched, err := ParseStandard(c.spec)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
actual := sched.Next(getTimeTZ(c.time))
|
|
||||||
expected := getTimeTZ(c.expected)
|
|
||||||
if !actual.Equal(expected) {
|
|
||||||
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTimeTZ(value string) time.Time {
|
|
||||||
if value == "" {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
t, err := time.Parse("Mon Jan 2 15:04 2006", value)
|
|
||||||
if err != nil {
|
|
||||||
t, err = time.Parse("Mon Jan 2 15:04:05 2006", value)
|
|
||||||
if err != nil {
|
|
||||||
t, err = time.Parse("2006-01-02T15:04:05-0700", value)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/robfig/cron/issues/144
|
|
||||||
func TestSlash0NoHang(t *testing.T) {
|
|
||||||
schedule := "TZ=America/New_York 15/0 * * * *"
|
|
||||||
_, err := ParseStandard(schedule)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected an error on 0 increment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# Maintaining the Memo Filter Engine
|
|
||||||
|
|
||||||
The engine is memo-specific; any future field or behavior changes must stay
|
|
||||||
consistent with the memo schema and store implementations. Use this guide when
|
|
||||||
extending or debugging the package.
|
|
||||||
|
|
||||||
## Adding a New Memo Field
|
|
||||||
|
|
||||||
1. **Update the schema**
|
|
||||||
- Add the field entry in `schema.go`.
|
|
||||||
- Define the backing column (`Column`), JSON path (if applicable), type, and
|
|
||||||
allowed operators.
|
|
||||||
- Include the CEL variable in `EnvOptions`.
|
|
||||||
2. **Adjust parser or renderer (if needed)**
|
|
||||||
- For non-scalar fields (JSON booleans, lists), add handling in
|
|
||||||
`parser.go` or extend the renderer helpers.
|
|
||||||
- Keep validation in the parser (e.g., reject unsupported operators).
|
|
||||||
3. **Write a golden test**
|
|
||||||
- Extend the dialect-specific memo filter tests under
|
|
||||||
`store/db/{sqlite,mysql,postgres}/memo_filter_test.go` with a case that
|
|
||||||
exercises the new field.
|
|
||||||
4. **Run `go test ./...`** to ensure the SQL output matches expectations across
|
|
||||||
all dialects.
|
|
||||||
|
|
||||||
## Supporting Dialect Nuances
|
|
||||||
|
|
||||||
- Centralize differences inside `render.go`. If a new dialect-specific behavior
|
|
||||||
emerges (e.g., JSON operators), add the logic there rather than leaking it
|
|
||||||
into store code.
|
|
||||||
- Use the renderer helpers (`jsonExtractExpr`, `jsonArrayExpr`, etc.) rather than
|
|
||||||
sprinkling ad-hoc SQL strings.
|
|
||||||
- When placeholders change, adjust `addArg` so that argument numbering stays in
|
|
||||||
sync with store queries.
|
|
||||||
|
|
||||||
## Debugging Tips
|
|
||||||
|
|
||||||
- **Parser errors** – Most originate in `buildCondition` or schema validation.
|
|
||||||
Enable logging around `parser.go` when diagnosing unknown identifier/operator
|
|
||||||
messages.
|
|
||||||
- **Renderer output** – Temporary printf/log statements in `renderCondition` help
|
|
||||||
identify which IR node produced unexpected SQL.
|
|
||||||
- **Store integration** – Ensure drivers call `filter.DefaultEngine()` exactly once
|
|
||||||
per process; the singleton caches the parsed CEL environment.
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- `go test ./store/...` ensures all dialect tests consume the engine correctly.
|
|
||||||
- Add targeted unit tests whenever new IR nodes or renderer paths are introduced.
|
|
||||||
- When changing boolean or JSON handling, verify all three dialect test suites
|
|
||||||
(SQLite, MySQL, Postgres) to avoid regression.
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# Memo Filter Engine
|
|
||||||
|
|
||||||
This package houses the memo-only filter engine that turns CEL expressions into
|
|
||||||
SQL fragments. The engine follows a three phase pipeline inspired by systems
|
|
||||||
such as Calcite or Prisma:
|
|
||||||
|
|
||||||
1. **Parsing** – CEL expressions are parsed with `cel-go` and validated against
|
|
||||||
the memo-specific environment declared in `schema.go`. Only fields that
|
|
||||||
exist in the schema can surface in the filter.
|
|
||||||
2. **Normalization** – the raw CEL AST is converted into an intermediate
|
|
||||||
representation (IR) defined in `ir.go`. The IR is a dialect-agnostic tree of
|
|
||||||
conditions (logical operators, comparisons, list membership, etc.). This
|
|
||||||
step enforces schema rules (e.g. operator compatibility, type checks).
|
|
||||||
3. **Rendering** – the renderer in `render.go` walks the IR and produces a SQL
|
|
||||||
fragment plus placeholder arguments tailored to a target dialect
|
|
||||||
(`sqlite`, `mysql`, or `postgres`). Dialect differences such as JSON access,
|
|
||||||
boolean semantics, placeholders, and `LIKE` vs `ILIKE` are encapsulated in
|
|
||||||
renderer helpers.
|
|
||||||
|
|
||||||
The entry point is `filter.DefaultEngine()` from `engine.go`. It lazily constructs
|
|
||||||
an `Engine` configured with the memo schema and exposes:
|
|
||||||
|
|
||||||
```go
|
|
||||||
engine, _ := filter.DefaultEngine()
|
|
||||||
stmt, _ := engine.CompileToStatement(ctx, `has_task_list && visibility == "PUBLIC"`, filter.RenderOptions{
|
|
||||||
Dialect: filter.DialectPostgres,
|
|
||||||
})
|
|
||||||
// stmt.SQL -> "((memo.payload->'property'->>'hasTaskList')::boolean IS TRUE AND memo.visibility = $1)"
|
|
||||||
// stmt.Args -> ["PUBLIC"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Files
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
| ------------- | ------------------------------------------------------------------------------- |
|
|
||||||
| `schema.go` | Declares memo fields, their types, backing columns, CEL environment options |
|
|
||||||
| `ir.go` | IR node definitions used across the pipeline |
|
|
||||||
| `parser.go` | Converts CEL `Expr` into IR while applying schema validation |
|
|
||||||
| `render.go` | Translates IR into SQL, handling dialect-specific behavior |
|
|
||||||
| `engine.go` | Glue between the phases; exposes `Compile`, `CompileToStatement`, and `DefaultEngine` |
|
|
||||||
| `helpers.go` | Convenience helpers for store integration (appending conditions) |
|
|
||||||
|
|
||||||
## SQL Generation Notes
|
|
||||||
|
|
||||||
- **Placeholders** — `?` is used for SQLite/MySQL, `$n` for Postgres. The renderer
|
|
||||||
tracks offsets to compose queries with pre-existing arguments.
|
|
||||||
- **JSON Fields** — Memo metadata lives in `memo.payload`. The renderer handles
|
|
||||||
`JSON_EXTRACT`/`json_extract`/`->`/`->>` variations and boolean coercion.
|
|
||||||
- **Tag Operations** — `tag in [...]` and `"tag" in tags` become JSON array
|
|
||||||
predicates. SQLite uses `LIKE` patterns, MySQL uses `JSON_CONTAINS`, and
|
|
||||||
Postgres uses `@>`.
|
|
||||||
- **Boolean Flags** — Fields such as `has_task_list` render as `IS TRUE` equality
|
|
||||||
checks, or comparisons against `CAST('true' AS JSON)` depending on the dialect.
|
|
||||||
|
|
||||||
## Typical Integration
|
|
||||||
|
|
||||||
1. Fetch the engine with `filter.DefaultEngine()`.
|
|
||||||
2. Call `CompileToStatement` using the appropriate dialect enum.
|
|
||||||
3. Append the emitted SQL fragment/args to the existing `WHERE` clause.
|
|
||||||
4. Execute the resulting query through the store driver.
|
|
||||||
|
|
||||||
The `helpers.AppendConditions` helper encapsulates steps 2–3 when a driver needs
|
|
||||||
to process an array of filters.
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
package filter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/google/cel-go/cel"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Engine parses CEL filters into a dialect-agnostic condition tree.
|
|
||||||
type Engine struct {
|
|
||||||
schema Schema
|
|
||||||
env *cel.Env
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewEngine builds a new Engine for the provided schema.
|
|
||||||
func NewEngine(schema Schema) (*Engine, error) {
|
|
||||||
env, err := cel.NewEnv(schema.EnvOptions...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to create CEL environment")
|
|
||||||
}
|
|
||||||
return &Engine{
|
|
||||||
schema: schema,
|
|
||||||
env: env,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Program stores a compiled filter condition.
|
|
||||||
type Program struct {
|
|
||||||
schema Schema
|
|
||||||
condition Condition
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConditionTree exposes the underlying condition tree.
|
|
||||||
func (p *Program) ConditionTree() Condition {
|
|
||||||
return p.condition
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile parses the filter string into an executable program.
|
|
||||||
func (e *Engine) Compile(_ context.Context, filter string) (*Program, error) {
|
|
||||||
if strings.TrimSpace(filter) == "" {
|
|
||||||
return nil, errors.New("filter expression is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
filter = normalizeLegacyFilter(filter)
|
|
||||||
|
|
||||||
ast, issues := e.env.Compile(filter)
|
|
||||||
if issues != nil && issues.Err() != nil {
|
|
||||||
return nil, errors.Wrap(issues.Err(), "failed to compile filter")
|
|
||||||
}
|
|
||||||
parsed, err := cel.AstToParsedExpr(ast)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to convert AST")
|
|
||||||
}
|
|
||||||
|
|
||||||
cond, err := buildCondition(parsed.GetExpr(), e.schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Program{
|
|
||||||
schema: e.schema,
|
|
||||||
condition: cond,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompileToStatement compiles and renders the filter in a single step.
|
|
||||||
func (e *Engine) CompileToStatement(ctx context.Context, filter string, opts RenderOptions) (Statement, error) {
|
|
||||||
program, err := e.Compile(ctx, filter)
|
|
||||||
if err != nil {
|
|
||||||
return Statement{}, err
|
|
||||||
}
|
|
||||||
return program.Render(opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderOptions configure SQL rendering.
|
|
||||||
type RenderOptions struct {
|
|
||||||
Dialect DialectName
|
|
||||||
PlaceholderOffset int
|
|
||||||
DisableNullChecks bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Statement contains the rendered SQL fragment and its args.
|
|
||||||
type Statement struct {
|
|
||||||
SQL string
|
|
||||||
Args []any
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render converts the program into a dialect-specific SQL fragment.
|
|
||||||
func (p *Program) Render(opts RenderOptions) (Statement, error) {
|
|
||||||
renderer := newRenderer(p.schema, opts)
|
|
||||||
return renderer.Render(p.condition)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
defaultOnce sync.Once
|
|
||||||
defaultInst *Engine
|
|
||||||
defaultErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultEngine returns the process-wide memo filter engine.
|
|
||||||
func DefaultEngine() (*Engine, error) {
|
|
||||||
defaultOnce.Do(func() {
|
|
||||||
defaultInst, defaultErr = NewEngine(NewSchema())
|
|
||||||
})
|
|
||||||
return defaultInst, defaultErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeLegacyFilter(expr string) string {
|
|
||||||
expr = rewriteNumericLogicalOperand(expr, "&&")
|
|
||||||
expr = rewriteNumericLogicalOperand(expr, "||")
|
|
||||||
return expr
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewriteNumericLogicalOperand(expr, op string) string {
|
|
||||||
var builder strings.Builder
|
|
||||||
n := len(expr)
|
|
||||||
i := 0
|
|
||||||
var inQuote rune
|
|
||||||
|
|
||||||
for i < n {
|
|
||||||
ch := expr[i]
|
|
||||||
|
|
||||||
if inQuote != 0 {
|
|
||||||
builder.WriteByte(ch)
|
|
||||||
if ch == '\\' && i+1 < n {
|
|
||||||
builder.WriteByte(expr[i+1])
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ch == byte(inQuote) {
|
|
||||||
inQuote = 0
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ch == '\'' || ch == '"' {
|
|
||||||
inQuote = rune(ch)
|
|
||||||
builder.WriteByte(ch)
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(expr[i:], op) {
|
|
||||||
builder.WriteString(op)
|
|
||||||
i += len(op)
|
|
||||||
|
|
||||||
// Preserve whitespace following the operator.
|
|
||||||
wsStart := i
|
|
||||||
for i < n && (expr[i] == ' ' || expr[i] == '\t') {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
builder.WriteString(expr[wsStart:i])
|
|
||||||
|
|
||||||
signStart := i
|
|
||||||
if i < n && (expr[i] == '+' || expr[i] == '-') {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
for i < n && expr[i] >= '0' && expr[i] <= '9' {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if i > signStart {
|
|
||||||
numLiteral := expr[signStart:i]
|
|
||||||
builder.WriteString(fmt.Sprintf("(%s != 0)", numLiteral))
|
|
||||||
} else {
|
|
||||||
builder.WriteString(expr[signStart:i])
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.WriteByte(ch)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package filter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AppendConditions compiles the provided filters and appends the resulting SQL fragments and args.
|
|
||||||
func AppendConditions(ctx context.Context, engine *Engine, filters []string, dialect DialectName, where *[]string, args *[]any) error {
|
|
||||||
for _, filterStr := range filters {
|
|
||||||
stmt, err := engine.CompileToStatement(ctx, filterStr, RenderOptions{
|
|
||||||
Dialect: dialect,
|
|
||||||
PlaceholderOffset: len(*args),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if stmt.SQL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
*where = append(*where, fmt.Sprintf("(%s)", stmt.SQL))
|
|
||||||
*args = append(*args, stmt.Args...)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
package filter
|
|
||||||
|
|
||||||
// Condition represents a boolean expression derived from the CEL filter.
|
|
||||||
type Condition interface {
|
|
||||||
isCondition()
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogicalOperator enumerates the supported logical operators.
|
|
||||||
type LogicalOperator string
|
|
||||||
|
|
||||||
const (
|
|
||||||
LogicalAnd LogicalOperator = "AND"
|
|
||||||
LogicalOr LogicalOperator = "OR"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LogicalCondition composes two conditions with a logical operator.
|
|
||||||
type LogicalCondition struct {
|
|
||||||
Operator LogicalOperator
|
|
||||||
Left Condition
|
|
||||||
Right Condition
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*LogicalCondition) isCondition() {}
|
|
||||||
|
|
||||||
// NotCondition negates a child condition.
|
|
||||||
type NotCondition struct {
|
|
||||||
Expr Condition
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*NotCondition) isCondition() {}
|
|
||||||
|
|
||||||
// FieldPredicateCondition asserts that a field evaluates to true.
|
|
||||||
type FieldPredicateCondition struct {
|
|
||||||
Field string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*FieldPredicateCondition) isCondition() {}
|
|
||||||
|
|
||||||
// ComparisonOperator lists supported comparison operators.
|
|
||||||
type ComparisonOperator string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CompareEq ComparisonOperator = "="
|
|
||||||
CompareNeq ComparisonOperator = "!="
|
|
||||||
CompareLt ComparisonOperator = "<"
|
|
||||||
CompareLte ComparisonOperator = "<="
|
|
||||||
CompareGt ComparisonOperator = ">"
|
|
||||||
CompareGte ComparisonOperator = ">="
|
|
||||||
)
|
|
||||||
|
|
||||||
// ComparisonCondition represents a binary comparison.
|
|
||||||
type ComparisonCondition struct {
|
|
||||||
Left ValueExpr
|
|
||||||
Operator ComparisonOperator
|
|
||||||
Right ValueExpr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*ComparisonCondition) isCondition() {}
|
|
||||||
|
|
||||||
// InCondition represents an IN predicate with literal list values.
|
|
||||||
type InCondition struct {
|
|
||||||
Left ValueExpr
|
|
||||||
Values []ValueExpr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*InCondition) isCondition() {}
|
|
||||||
|
|
||||||
// ElementInCondition represents the CEL syntax `"value" in field`.
|
|
||||||
type ElementInCondition struct {
|
|
||||||
Element ValueExpr
|
|
||||||
Field string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*ElementInCondition) isCondition() {}
|
|
||||||
|
|
||||||
// ContainsCondition models the <field>.contains(<value>) call.
|
|
||||||
type ContainsCondition struct {
|
|
||||||
Field string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*ContainsCondition) isCondition() {}
|
|
||||||
|
|
||||||
// ConstantCondition captures a literal boolean outcome.
|
|
||||||
type ConstantCondition struct {
|
|
||||||
Value bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*ConstantCondition) isCondition() {}
|
|
||||||
|
|
||||||
// ValueExpr models arithmetic or scalar expressions whose result feeds a comparison.
|
|
||||||
type ValueExpr interface {
|
|
||||||
isValueExpr()
|
|
||||||
}
|
|
||||||
|
|
||||||
// FieldRef references a named schema field.
|
|
||||||
type FieldRef struct {
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*FieldRef) isValueExpr() {}
|
|
||||||
|
|
||||||
// LiteralValue holds a literal scalar.
|
|
||||||
type LiteralValue struct {
|
|
||||||
Value interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*LiteralValue) isValueExpr() {}
|
|
||||||
|
|
||||||
// FunctionValue captures simple function calls like size(tags).
|
|
||||||
type FunctionValue struct {
|
|
||||||
Name string
|
|
||||||
Args []ValueExpr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*FunctionValue) isValueExpr() {}
|
|
||||||
|
|
@ -1,417 +0,0 @@
|
||||||
package filter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
exprv1 "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func buildCondition(expr *exprv1.Expr, schema Schema) (Condition, error) {
|
|
||||||
switch v := expr.ExprKind.(type) {
|
|
||||||
case *exprv1.Expr_CallExpr:
|
|
||||||
return buildCallCondition(v.CallExpr, schema)
|
|
||||||
case *exprv1.Expr_ConstExpr:
|
|
||||||
val, err := getConstValue(expr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch v := val.(type) {
|
|
||||||
case bool:
|
|
||||||
return &ConstantCondition{Value: v}, nil
|
|
||||||
case int64:
|
|
||||||
return &ConstantCondition{Value: v != 0}, nil
|
|
||||||
case float64:
|
|
||||||
return &ConstantCondition{Value: v != 0}, nil
|
|
||||||
default:
|
|
||||||
return nil, errors.New("filter must evaluate to a boolean value")
|
|
||||||
}
|
|
||||||
case *exprv1.Expr_IdentExpr:
|
|
||||||
name := v.IdentExpr.GetName()
|
|
||||||
field, ok := schema.Field(name)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.Errorf("unknown identifier %q", name)
|
|
||||||
}
|
|
||||||
if field.Type != FieldTypeBool {
|
|
||||||
return nil, errors.Errorf("identifier %q is not boolean", name)
|
|
||||||
}
|
|
||||||
return &FieldPredicateCondition{Field: name}, nil
|
|
||||||
default:
|
|
||||||
return nil, errors.New("unsupported top-level expression")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCallCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error) {
|
|
||||||
switch call.Function {
|
|
||||||
case "_&&_":
|
|
||||||
if len(call.Args) != 2 {
|
|
||||||
return nil, errors.New("logical AND expects two arguments")
|
|
||||||
}
|
|
||||||
left, err := buildCondition(call.Args[0], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
right, err := buildCondition(call.Args[1], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &LogicalCondition{
|
|
||||||
Operator: LogicalAnd,
|
|
||||||
Left: left,
|
|
||||||
Right: right,
|
|
||||||
}, nil
|
|
||||||
case "_||_":
|
|
||||||
if len(call.Args) != 2 {
|
|
||||||
return nil, errors.New("logical OR expects two arguments")
|
|
||||||
}
|
|
||||||
left, err := buildCondition(call.Args[0], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
right, err := buildCondition(call.Args[1], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &LogicalCondition{
|
|
||||||
Operator: LogicalOr,
|
|
||||||
Left: left,
|
|
||||||
Right: right,
|
|
||||||
}, nil
|
|
||||||
case "!_":
|
|
||||||
if len(call.Args) != 1 {
|
|
||||||
return nil, errors.New("logical NOT expects one argument")
|
|
||||||
}
|
|
||||||
child, err := buildCondition(call.Args[0], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &NotCondition{Expr: child}, nil
|
|
||||||
case "_==_", "_!=_", "_<_", "_>_", "_<=_", "_>=_":
|
|
||||||
return buildComparisonCondition(call, schema)
|
|
||||||
case "@in":
|
|
||||||
return buildInCondition(call, schema)
|
|
||||||
case "contains":
|
|
||||||
return buildContainsCondition(call, schema)
|
|
||||||
default:
|
|
||||||
val, ok, err := evaluateBool(call)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
return &ConstantCondition{Value: val}, nil
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unsupported call expression %q", call.Function)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildComparisonCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error) {
|
|
||||||
if len(call.Args) != 2 {
|
|
||||||
return nil, errors.New("comparison expects two arguments")
|
|
||||||
}
|
|
||||||
op, err := toComparisonOperator(call.Function)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
left, err := buildValueExpr(call.Args[0], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
right, err := buildValueExpr(call.Args[1], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the left side is a field, validate allowed operators.
|
|
||||||
if field, ok := left.(*FieldRef); ok {
|
|
||||||
def, exists := schema.Field(field.Name)
|
|
||||||
if !exists {
|
|
||||||
return nil, errors.Errorf("unknown identifier %q", field.Name)
|
|
||||||
}
|
|
||||||
if def.Kind == FieldKindVirtualAlias {
|
|
||||||
def, exists = schema.ResolveAlias(field.Name)
|
|
||||||
if !exists {
|
|
||||||
return nil, errors.Errorf("invalid alias %q", field.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if def.AllowedComparisonOps != nil {
|
|
||||||
if _, allowed := def.AllowedComparisonOps[op]; !allowed {
|
|
||||||
return nil, errors.Errorf("operator %s not allowed for field %q", op, field.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ComparisonCondition{
|
|
||||||
Left: left,
|
|
||||||
Operator: op,
|
|
||||||
Right: right,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildInCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error) {
|
|
||||||
if len(call.Args) != 2 {
|
|
||||||
return nil, errors.New("in operator expects two arguments")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle identifier in list syntax.
|
|
||||||
if identName, err := getIdentName(call.Args[0]); err == nil {
|
|
||||||
if field, ok := schema.Field(identName); ok && field.Kind == FieldKindVirtualAlias {
|
|
||||||
if _, aliasOk := schema.ResolveAlias(identName); !aliasOk {
|
|
||||||
return nil, errors.Errorf("invalid alias %q", identName)
|
|
||||||
}
|
|
||||||
} else if !ok {
|
|
||||||
return nil, errors.Errorf("unknown identifier %q", identName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if listExpr := call.Args[1].GetListExpr(); listExpr != nil {
|
|
||||||
values := make([]ValueExpr, 0, len(listExpr.Elements))
|
|
||||||
for _, element := range listExpr.Elements {
|
|
||||||
value, err := buildValueExpr(element, schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
return &InCondition{
|
|
||||||
Left: &FieldRef{Name: identName},
|
|
||||||
Values: values,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle "value in identifier" syntax.
|
|
||||||
if identName, err := getIdentName(call.Args[1]); err == nil {
|
|
||||||
if _, ok := schema.Field(identName); !ok {
|
|
||||||
return nil, errors.Errorf("unknown identifier %q", identName)
|
|
||||||
}
|
|
||||||
element, err := buildValueExpr(call.Args[0], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &ElementInCondition{
|
|
||||||
Element: element,
|
|
||||||
Field: identName,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.New("invalid use of in operator")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildContainsCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error) {
|
|
||||||
if call.Target == nil {
|
|
||||||
return nil, errors.New("contains requires a target")
|
|
||||||
}
|
|
||||||
targetName, err := getIdentName(call.Target)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
field, ok := schema.Field(targetName)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.Errorf("unknown identifier %q", targetName)
|
|
||||||
}
|
|
||||||
if !field.SupportsContains {
|
|
||||||
return nil, errors.Errorf("identifier %q does not support contains()", targetName)
|
|
||||||
}
|
|
||||||
if len(call.Args) != 1 {
|
|
||||||
return nil, errors.New("contains expects exactly one argument")
|
|
||||||
}
|
|
||||||
value, err := getConstValue(call.Args[0])
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "contains only supports literal arguments")
|
|
||||||
}
|
|
||||||
str, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("contains argument must be a string")
|
|
||||||
}
|
|
||||||
return &ContainsCondition{
|
|
||||||
Field: targetName,
|
|
||||||
Value: str,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildValueExpr(expr *exprv1.Expr, schema Schema) (ValueExpr, error) {
|
|
||||||
if identName, err := getIdentName(expr); err == nil {
|
|
||||||
if _, ok := schema.Field(identName); !ok {
|
|
||||||
return nil, errors.Errorf("unknown identifier %q", identName)
|
|
||||||
}
|
|
||||||
return &FieldRef{Name: identName}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if literal, err := getConstValue(expr); err == nil {
|
|
||||||
return &LiteralValue{Value: literal}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if value, ok, err := evaluateNumeric(expr); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if ok {
|
|
||||||
return &LiteralValue{Value: value}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if boolVal, ok, err := evaluateBoolExpr(expr); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if ok {
|
|
||||||
return &LiteralValue{Value: boolVal}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if call := expr.GetCallExpr(); call != nil {
|
|
||||||
switch call.Function {
|
|
||||||
case "size":
|
|
||||||
if len(call.Args) != 1 {
|
|
||||||
return nil, errors.New("size() expects one argument")
|
|
||||||
}
|
|
||||||
arg, err := buildValueExpr(call.Args[0], schema)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &FunctionValue{
|
|
||||||
Name: "size",
|
|
||||||
Args: []ValueExpr{arg},
|
|
||||||
}, nil
|
|
||||||
case "now":
|
|
||||||
return &LiteralValue{Value: timeNowUnix()}, nil
|
|
||||||
case "_+_", "_-_", "_*_":
|
|
||||||
value, ok, err := evaluateNumeric(expr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
return &LiteralValue{Value: value}, nil
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Fall through to error return below
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.New("unsupported value expression")
|
|
||||||
}
|
|
||||||
|
|
||||||
func toComparisonOperator(fn string) (ComparisonOperator, error) {
|
|
||||||
switch fn {
|
|
||||||
case "_==_":
|
|
||||||
return CompareEq, nil
|
|
||||||
case "_!=_":
|
|
||||||
return CompareNeq, nil
|
|
||||||
case "_<_":
|
|
||||||
return CompareLt, nil
|
|
||||||
case "_>_":
|
|
||||||
return CompareGt, nil
|
|
||||||
case "_<=_":
|
|
||||||
return CompareLte, nil
|
|
||||||
case "_>=_":
|
|
||||||
return CompareGte, nil
|
|
||||||
default:
|
|
||||||
return "", errors.Errorf("unsupported comparison operator %q", fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getIdentName(expr *exprv1.Expr) (string, error) {
|
|
||||||
if ident := expr.GetIdentExpr(); ident != nil {
|
|
||||||
return ident.GetName(), nil
|
|
||||||
}
|
|
||||||
return "", errors.New("expression is not an identifier")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getConstValue(expr *exprv1.Expr) (interface{}, error) {
|
|
||||||
v, ok := expr.ExprKind.(*exprv1.Expr_ConstExpr)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("expression is not a literal")
|
|
||||||
}
|
|
||||||
switch x := v.ConstExpr.ConstantKind.(type) {
|
|
||||||
case *exprv1.Constant_StringValue:
|
|
||||||
return v.ConstExpr.GetStringValue(), nil
|
|
||||||
case *exprv1.Constant_Int64Value:
|
|
||||||
return v.ConstExpr.GetInt64Value(), nil
|
|
||||||
case *exprv1.Constant_Uint64Value:
|
|
||||||
return int64(v.ConstExpr.GetUint64Value()), nil
|
|
||||||
case *exprv1.Constant_DoubleValue:
|
|
||||||
return v.ConstExpr.GetDoubleValue(), nil
|
|
||||||
case *exprv1.Constant_BoolValue:
|
|
||||||
return v.ConstExpr.GetBoolValue(), nil
|
|
||||||
case *exprv1.Constant_NullValue:
|
|
||||||
return nil, nil
|
|
||||||
default:
|
|
||||||
return nil, errors.Errorf("unsupported constant %T", x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluateBool(call *exprv1.Expr_Call) (bool, bool, error) {
|
|
||||||
val, ok, err := evaluateBoolExpr(&exprv1.Expr{ExprKind: &exprv1.Expr_CallExpr{CallExpr: call}})
|
|
||||||
return val, ok, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluateBoolExpr(expr *exprv1.Expr) (bool, bool, error) {
|
|
||||||
if literal, err := getConstValue(expr); err == nil {
|
|
||||||
if b, ok := literal.(bool); ok {
|
|
||||||
return b, true, nil
|
|
||||||
}
|
|
||||||
return false, false, nil
|
|
||||||
}
|
|
||||||
if call := expr.GetCallExpr(); call != nil && call.Function == "!_" {
|
|
||||||
if len(call.Args) != 1 {
|
|
||||||
return false, false, errors.New("NOT expects exactly one argument")
|
|
||||||
}
|
|
||||||
val, ok, err := evaluateBoolExpr(call.Args[0])
|
|
||||||
if err != nil || !ok {
|
|
||||||
return false, false, err
|
|
||||||
}
|
|
||||||
return !val, true, nil
|
|
||||||
}
|
|
||||||
return false, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluateNumeric(expr *exprv1.Expr) (int64, bool, error) {
|
|
||||||
if literal, err := getConstValue(expr); err == nil {
|
|
||||||
switch v := literal.(type) {
|
|
||||||
case int64:
|
|
||||||
return v, true, nil
|
|
||||||
case float64:
|
|
||||||
return int64(v), true, nil
|
|
||||||
}
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
call := expr.GetCallExpr()
|
|
||||||
if call == nil {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch call.Function {
|
|
||||||
case "now":
|
|
||||||
return timeNowUnix(), true, nil
|
|
||||||
case "_+_", "_-_", "_*_":
|
|
||||||
if len(call.Args) != 2 {
|
|
||||||
return 0, false, errors.New("arithmetic requires two arguments")
|
|
||||||
}
|
|
||||||
left, ok, err := evaluateNumeric(call.Args[0])
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
right, ok, err := evaluateNumeric(call.Args[1])
|
|
||||||
if err != nil {
|
|
||||||
return 0, false, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
switch call.Function {
|
|
||||||
case "_+_":
|
|
||||||
return left + right, true, nil
|
|
||||||
case "_-_":
|
|
||||||
return left - right, true, nil
|
|
||||||
case "_*_":
|
|
||||||
return left * right, true, nil
|
|
||||||
default:
|
|
||||||
return 0, false, errors.Errorf("unsupported arithmetic operator %q", call.Function)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeNowUnix() int64 {
|
|
||||||
return time.Now().Unix()
|
|
||||||
}
|
|
||||||
|
|
@ -1,635 +0,0 @@
|
||||||
package filter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type renderer struct {
|
|
||||||
schema Schema
|
|
||||||
dialect DialectName
|
|
||||||
placeholderOffset int
|
|
||||||
placeholderCounter int
|
|
||||||
args []any
|
|
||||||
}
|
|
||||||
|
|
||||||
type renderResult struct {
|
|
||||||
sql string
|
|
||||||
trivial bool
|
|
||||||
unsatisfiable bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRenderer(schema Schema, opts RenderOptions) *renderer {
|
|
||||||
return &renderer{
|
|
||||||
schema: schema,
|
|
||||||
dialect: opts.Dialect,
|
|
||||||
placeholderOffset: opts.PlaceholderOffset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) Render(cond Condition) (Statement, error) {
|
|
||||||
result, err := r.renderCondition(cond)
|
|
||||||
if err != nil {
|
|
||||||
return Statement{}, err
|
|
||||||
}
|
|
||||||
args := r.args
|
|
||||||
if args == nil {
|
|
||||||
args = []any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case result.unsatisfiable:
|
|
||||||
return Statement{
|
|
||||||
SQL: "1 = 0",
|
|
||||||
Args: args,
|
|
||||||
}, nil
|
|
||||||
case result.trivial:
|
|
||||||
return Statement{
|
|
||||||
SQL: "",
|
|
||||||
Args: args,
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
return Statement{
|
|
||||||
SQL: result.sql,
|
|
||||||
Args: args,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderCondition(cond Condition) (renderResult, error) {
|
|
||||||
switch c := cond.(type) {
|
|
||||||
case *LogicalCondition:
|
|
||||||
return r.renderLogicalCondition(c)
|
|
||||||
case *NotCondition:
|
|
||||||
return r.renderNotCondition(c)
|
|
||||||
case *FieldPredicateCondition:
|
|
||||||
return r.renderFieldPredicate(c)
|
|
||||||
case *ComparisonCondition:
|
|
||||||
return r.renderComparison(c)
|
|
||||||
case *InCondition:
|
|
||||||
return r.renderInCondition(c)
|
|
||||||
case *ElementInCondition:
|
|
||||||
return r.renderElementInCondition(c)
|
|
||||||
case *ContainsCondition:
|
|
||||||
return r.renderContainsCondition(c)
|
|
||||||
case *ConstantCondition:
|
|
||||||
if c.Value {
|
|
||||||
return renderResult{trivial: true}, nil
|
|
||||||
}
|
|
||||||
return renderResult{sql: "1 = 0", unsatisfiable: true}, nil
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("unsupported condition type %T", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderLogicalCondition(cond *LogicalCondition) (renderResult, error) {
|
|
||||||
left, err := r.renderCondition(cond.Left)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
right, err := r.renderCondition(cond.Right)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch cond.Operator {
|
|
||||||
case LogicalAnd:
|
|
||||||
return combineAnd(left, right), nil
|
|
||||||
case LogicalOr:
|
|
||||||
return combineOr(left, right), nil
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("unsupported logical operator %s", cond.Operator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderNotCondition(cond *NotCondition) (renderResult, error) {
|
|
||||||
child, err := r.renderCondition(cond.Expr)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if child.trivial {
|
|
||||||
return renderResult{sql: "1 = 0", unsatisfiable: true}, nil
|
|
||||||
}
|
|
||||||
if child.unsatisfiable {
|
|
||||||
return renderResult{trivial: true}, nil
|
|
||||||
}
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("NOT (%s)", child.sql),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderFieldPredicate(cond *FieldPredicateCondition) (renderResult, error) {
|
|
||||||
field, ok := r.schema.Field(cond.Field)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch field.Kind {
|
|
||||||
case FieldKindBoolColumn:
|
|
||||||
column := qualifyColumn(r.dialect, field.Column)
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("%s IS TRUE", column),
|
|
||||||
}, nil
|
|
||||||
case FieldKindJSONBool:
|
|
||||||
sql, err := r.jsonBoolPredicate(field)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
return renderResult{sql: sql}, nil
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("field %q cannot be used as a predicate", cond.Field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderComparison(cond *ComparisonCondition) (renderResult, error) {
|
|
||||||
switch left := cond.Left.(type) {
|
|
||||||
case *FieldRef:
|
|
||||||
field, ok := r.schema.Field(left.Name)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("unknown field %q", left.Name)
|
|
||||||
}
|
|
||||||
switch field.Kind {
|
|
||||||
case FieldKindBoolColumn:
|
|
||||||
return r.renderBoolColumnComparison(field, cond.Operator, cond.Right)
|
|
||||||
case FieldKindJSONBool:
|
|
||||||
return r.renderJSONBoolComparison(field, cond.Operator, cond.Right)
|
|
||||||
case FieldKindScalar:
|
|
||||||
return r.renderScalarComparison(field, cond.Operator, cond.Right)
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("field %q does not support comparison", field.Name)
|
|
||||||
}
|
|
||||||
case *FunctionValue:
|
|
||||||
return r.renderFunctionComparison(left, cond.Operator, cond.Right)
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.New("comparison must start with a field reference or supported function")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderFunctionComparison(fn *FunctionValue, op ComparisonOperator, right ValueExpr) (renderResult, error) {
|
|
||||||
if fn.Name != "size" {
|
|
||||||
return renderResult{}, errors.Errorf("unsupported function %s in comparison", fn.Name)
|
|
||||||
}
|
|
||||||
if len(fn.Args) != 1 {
|
|
||||||
return renderResult{}, errors.New("size() expects one argument")
|
|
||||||
}
|
|
||||||
fieldArg, ok := fn.Args[0].(*FieldRef)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.New("size() argument must be a field")
|
|
||||||
}
|
|
||||||
|
|
||||||
field, ok := r.schema.Field(fieldArg.Name)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("unknown field %q", fieldArg.Name)
|
|
||||||
}
|
|
||||||
if field.Kind != FieldKindJSONList {
|
|
||||||
return renderResult{}, errors.Errorf("size() only supports tag lists, got %q", field.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := expectNumericLiteral(right)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
expr := jsonArrayLengthExpr(r.dialect, field)
|
|
||||||
placeholder := r.addArg(value)
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("%s %s %s", expr, sqlOperator(op), placeholder),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderScalarComparison(field Field, op ComparisonOperator, right ValueExpr) (renderResult, error) {
|
|
||||||
lit, err := expectLiteral(right)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
columnExpr := field.columnExpr(r.dialect)
|
|
||||||
placeholder := ""
|
|
||||||
switch field.Type {
|
|
||||||
case FieldTypeString:
|
|
||||||
value, ok := lit.(string)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("field %q expects string value", field.Name)
|
|
||||||
}
|
|
||||||
placeholder = r.addArg(value)
|
|
||||||
case FieldTypeInt, FieldTypeTimestamp:
|
|
||||||
num, err := toInt64(lit)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, errors.Wrapf(err, "field %q expects integer value", field.Name)
|
|
||||||
}
|
|
||||||
placeholder = r.addArg(num)
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("unsupported data type %q for field %s", field.Type, field.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("%s %s %s", columnExpr, sqlOperator(op), placeholder),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderBoolColumnComparison(field Field, op ComparisonOperator, right ValueExpr) (renderResult, error) {
|
|
||||||
value, err := expectBool(right)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
placeholder := r.addBoolArg(value)
|
|
||||||
column := qualifyColumn(r.dialect, field.Column)
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("%s %s %s", column, sqlOperator(op), placeholder),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderJSONBoolComparison(field Field, op ComparisonOperator, right ValueExpr) (renderResult, error) {
|
|
||||||
value, err := expectBool(right)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonExpr := jsonExtractExpr(r.dialect, field)
|
|
||||||
switch r.dialect {
|
|
||||||
case DialectSQLite:
|
|
||||||
switch op {
|
|
||||||
case CompareEq:
|
|
||||||
if field.Name == "has_task_list" {
|
|
||||||
target := "0"
|
|
||||||
if value {
|
|
||||||
target = "1"
|
|
||||||
}
|
|
||||||
return renderResult{sql: fmt.Sprintf("%s = %s", jsonExpr, target)}, nil
|
|
||||||
}
|
|
||||||
if value {
|
|
||||||
return renderResult{sql: fmt.Sprintf("%s IS TRUE", jsonExpr)}, nil
|
|
||||||
}
|
|
||||||
return renderResult{sql: fmt.Sprintf("NOT(%s IS TRUE)", jsonExpr)}, nil
|
|
||||||
case CompareNeq:
|
|
||||||
if field.Name == "has_task_list" {
|
|
||||||
target := "0"
|
|
||||||
if value {
|
|
||||||
target = "1"
|
|
||||||
}
|
|
||||||
return renderResult{sql: fmt.Sprintf("%s != %s", jsonExpr, target)}, nil
|
|
||||||
}
|
|
||||||
if value {
|
|
||||||
return renderResult{sql: fmt.Sprintf("NOT(%s IS TRUE)", jsonExpr)}, nil
|
|
||||||
}
|
|
||||||
return renderResult{sql: fmt.Sprintf("%s IS TRUE", jsonExpr)}, nil
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("operator %s not supported for boolean JSON field", op)
|
|
||||||
}
|
|
||||||
case DialectMySQL:
|
|
||||||
boolStr := "false"
|
|
||||||
if value {
|
|
||||||
boolStr = "true"
|
|
||||||
}
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("%s %s CAST('%s' AS JSON)", jsonExpr, sqlOperator(op), boolStr),
|
|
||||||
}, nil
|
|
||||||
case DialectPostgres:
|
|
||||||
placeholder := r.addArg(value)
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("(%s)::boolean %s %s", jsonExpr, sqlOperator(op), placeholder),
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderInCondition(cond *InCondition) (renderResult, error) {
|
|
||||||
fieldRef, ok := cond.Left.(*FieldRef)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.New("IN operator requires a field on the left-hand side")
|
|
||||||
}
|
|
||||||
|
|
||||||
if fieldRef.Name == "tag" {
|
|
||||||
return r.renderTagInList(cond.Values)
|
|
||||||
}
|
|
||||||
|
|
||||||
field, ok := r.schema.Field(fieldRef.Name)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("unknown field %q", fieldRef.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if field.Kind != FieldKindScalar {
|
|
||||||
return renderResult{}, errors.Errorf("field %q does not support IN()", fieldRef.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.renderScalarInCondition(field, cond.Values)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderTagInList(values []ValueExpr) (renderResult, error) {
|
|
||||||
field, ok := r.schema.ResolveAlias("tag")
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.New("tag attribute is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
conditions := make([]string, 0, len(values))
|
|
||||||
for _, v := range values {
|
|
||||||
lit, err := expectLiteral(v)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
str, ok := lit.(string)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.New("tags must be compared with string literals")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.dialect {
|
|
||||||
case DialectSQLite:
|
|
||||||
// Support hierarchical tags: match exact tag OR tags with this prefix (e.g., "book" matches "book" and "book/something")
|
|
||||||
exactMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s"%%`, str)))
|
|
||||||
prefixMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str)))
|
|
||||||
expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
|
|
||||||
conditions = append(conditions, expr)
|
|
||||||
case DialectMySQL:
|
|
||||||
// Support hierarchical tags: match exact tag OR tags with this prefix
|
|
||||||
exactMatch := fmt.Sprintf("JSON_CONTAINS(%s, %s)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
|
|
||||||
prefixMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str)))
|
|
||||||
expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
|
|
||||||
conditions = append(conditions, expr)
|
|
||||||
case DialectPostgres:
|
|
||||||
// Support hierarchical tags: match exact tag OR tags with this prefix
|
|
||||||
exactMatch := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
|
|
||||||
prefixMatch := fmt.Sprintf("(%s)::text LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str)))
|
|
||||||
expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
|
|
||||||
conditions = append(conditions, expr)
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(conditions) == 1 {
|
|
||||||
return renderResult{sql: conditions[0]}, nil
|
|
||||||
}
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderElementInCondition(cond *ElementInCondition) (renderResult, error) {
|
|
||||||
field, ok := r.schema.Field(cond.Field)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
|
|
||||||
}
|
|
||||||
if field.Kind != FieldKindJSONList {
|
|
||||||
return renderResult{}, errors.Errorf("field %q is not a tag list", cond.Field)
|
|
||||||
}
|
|
||||||
|
|
||||||
lit, err := expectLiteral(cond.Element)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
str, ok := lit.(string)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.New("tags membership requires string literal")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.dialect {
|
|
||||||
case DialectSQLite:
|
|
||||||
sql := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s"%%`, str)))
|
|
||||||
return renderResult{sql: sql}, nil
|
|
||||||
case DialectMySQL:
|
|
||||||
sql := fmt.Sprintf("JSON_CONTAINS(%s, %s)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
|
|
||||||
return renderResult{sql: sql}, nil
|
|
||||||
case DialectPostgres:
|
|
||||||
sql := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
|
|
||||||
return renderResult{sql: sql}, nil
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderScalarInCondition(field Field, values []ValueExpr) (renderResult, error) {
|
|
||||||
placeholders := make([]string, 0, len(values))
|
|
||||||
|
|
||||||
for _, v := range values {
|
|
||||||
lit, err := expectLiteral(v)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
switch field.Type {
|
|
||||||
case FieldTypeString:
|
|
||||||
str, ok := lit.(string)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("field %q expects string values", field.Name)
|
|
||||||
}
|
|
||||||
placeholders = append(placeholders, r.addArg(str))
|
|
||||||
case FieldTypeInt:
|
|
||||||
num, err := toInt64(lit)
|
|
||||||
if err != nil {
|
|
||||||
return renderResult{}, err
|
|
||||||
}
|
|
||||||
placeholders = append(placeholders, r.addArg(num))
|
|
||||||
default:
|
|
||||||
return renderResult{}, errors.Errorf("field %q does not support IN() comparisons", field.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
column := field.columnExpr(r.dialect)
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ",")),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) renderContainsCondition(cond *ContainsCondition) (renderResult, error) {
|
|
||||||
field, ok := r.schema.Field(cond.Field)
|
|
||||||
if !ok {
|
|
||||||
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
|
|
||||||
}
|
|
||||||
column := field.columnExpr(r.dialect)
|
|
||||||
arg := fmt.Sprintf("%%%s%%", cond.Value)
|
|
||||||
switch r.dialect {
|
|
||||||
case DialectPostgres:
|
|
||||||
sql := fmt.Sprintf("%s ILIKE %s", column, r.addArg(arg))
|
|
||||||
return renderResult{sql: sql}, nil
|
|
||||||
default:
|
|
||||||
sql := fmt.Sprintf("%s LIKE %s", column, r.addArg(arg))
|
|
||||||
return renderResult{sql: sql}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) jsonBoolPredicate(field Field) (string, error) {
|
|
||||||
expr := jsonExtractExpr(r.dialect, field)
|
|
||||||
switch r.dialect {
|
|
||||||
case DialectSQLite:
|
|
||||||
return fmt.Sprintf("%s IS TRUE", expr), nil
|
|
||||||
case DialectMySQL:
|
|
||||||
return fmt.Sprintf("%s = CAST('true' AS JSON)", expr), nil
|
|
||||||
case DialectPostgres:
|
|
||||||
return fmt.Sprintf("(%s)::boolean IS TRUE", expr), nil
|
|
||||||
default:
|
|
||||||
return "", errors.Errorf("unsupported dialect %s", r.dialect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func combineAnd(left, right renderResult) renderResult {
|
|
||||||
if left.unsatisfiable || right.unsatisfiable {
|
|
||||||
return renderResult{sql: "1 = 0", unsatisfiable: true}
|
|
||||||
}
|
|
||||||
if left.trivial {
|
|
||||||
return right
|
|
||||||
}
|
|
||||||
if right.trivial {
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("(%s AND %s)", left.sql, right.sql),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func combineOr(left, right renderResult) renderResult {
|
|
||||||
if left.trivial || right.trivial {
|
|
||||||
return renderResult{trivial: true}
|
|
||||||
}
|
|
||||||
if left.unsatisfiable {
|
|
||||||
return right
|
|
||||||
}
|
|
||||||
if right.unsatisfiable {
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
return renderResult{
|
|
||||||
sql: fmt.Sprintf("(%s OR %s)", left.sql, right.sql),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) addArg(value any) string {
|
|
||||||
r.placeholderCounter++
|
|
||||||
r.args = append(r.args, value)
|
|
||||||
if r.dialect == DialectPostgres {
|
|
||||||
return fmt.Sprintf("$%d", r.placeholderOffset+r.placeholderCounter)
|
|
||||||
}
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *renderer) addBoolArg(value bool) string {
|
|
||||||
var v any
|
|
||||||
switch r.dialect {
|
|
||||||
case DialectSQLite:
|
|
||||||
if value {
|
|
||||||
v = 1
|
|
||||||
} else {
|
|
||||||
v = 0
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
v = value
|
|
||||||
}
|
|
||||||
return r.addArg(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func expectLiteral(expr ValueExpr) (any, error) {
|
|
||||||
lit, ok := expr.(*LiteralValue)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("expression must be a literal")
|
|
||||||
}
|
|
||||||
return lit.Value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func expectBool(expr ValueExpr) (bool, error) {
|
|
||||||
lit, err := expectLiteral(expr)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
value, ok := lit.(bool)
|
|
||||||
if !ok {
|
|
||||||
return false, errors.New("boolean literal required")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func expectNumericLiteral(expr ValueExpr) (int64, error) {
|
|
||||||
lit, err := expectLiteral(expr)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return toInt64(lit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toInt64(value any) (int64, error) {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case int:
|
|
||||||
return int64(v), nil
|
|
||||||
case int32:
|
|
||||||
return int64(v), nil
|
|
||||||
case int64:
|
|
||||||
return v, nil
|
|
||||||
case uint32:
|
|
||||||
return int64(v), nil
|
|
||||||
case uint64:
|
|
||||||
return int64(v), nil
|
|
||||||
case float32:
|
|
||||||
return int64(v), nil
|
|
||||||
case float64:
|
|
||||||
return int64(v), nil
|
|
||||||
default:
|
|
||||||
return 0, errors.Errorf("cannot convert %T to int64", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sqlOperator(op ComparisonOperator) string {
|
|
||||||
return string(op)
|
|
||||||
}
|
|
||||||
|
|
||||||
func qualifyColumn(d DialectName, col Column) string {
|
|
||||||
switch d {
|
|
||||||
case DialectPostgres:
|
|
||||||
return fmt.Sprintf("%s.%s", col.Table, col.Name)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("`%s`.`%s`", col.Table, col.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonPath(field Field) string {
|
|
||||||
return "$." + strings.Join(field.JSONPath, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonExtractExpr(d DialectName, field Field) string {
|
|
||||||
column := qualifyColumn(d, field.Column)
|
|
||||||
switch d {
|
|
||||||
case DialectSQLite, DialectMySQL:
|
|
||||||
return fmt.Sprintf("JSON_EXTRACT(%s, '%s')", column, jsonPath(field))
|
|
||||||
case DialectPostgres:
|
|
||||||
return buildPostgresJSONAccessor(column, field.JSONPath, true)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonArrayExpr(d DialectName, field Field) string {
|
|
||||||
column := qualifyColumn(d, field.Column)
|
|
||||||
switch d {
|
|
||||||
case DialectSQLite, DialectMySQL:
|
|
||||||
return fmt.Sprintf("JSON_EXTRACT(%s, '%s')", column, jsonPath(field))
|
|
||||||
case DialectPostgres:
|
|
||||||
return buildPostgresJSONAccessor(column, field.JSONPath, false)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func jsonArrayLengthExpr(d DialectName, field Field) string {
|
|
||||||
arrayExpr := jsonArrayExpr(d, field)
|
|
||||||
switch d {
|
|
||||||
case DialectSQLite:
|
|
||||||
return fmt.Sprintf("JSON_ARRAY_LENGTH(COALESCE(%s, JSON_ARRAY()))", arrayExpr)
|
|
||||||
case DialectMySQL:
|
|
||||||
return fmt.Sprintf("JSON_LENGTH(COALESCE(%s, JSON_ARRAY()))", arrayExpr)
|
|
||||||
case DialectPostgres:
|
|
||||||
return fmt.Sprintf("jsonb_array_length(COALESCE(%s, '[]'::jsonb))", arrayExpr)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPostgresJSONAccessor(base string, path []string, terminalText bool) string {
|
|
||||||
expr := base
|
|
||||||
for idx, part := range path {
|
|
||||||
if idx == len(path)-1 && terminalText {
|
|
||||||
expr = fmt.Sprintf("%s->>'%s'", expr, part)
|
|
||||||
} else {
|
|
||||||
expr = fmt.Sprintf("%s->'%s'", expr, part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return expr
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue