Compare commits
55 Commits
v2.0.1-RC2
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| 65f0060888 | |||
| 2c0ced322d | |||
| a203e7f608 | |||
| 63a67542f4 | |||
| 2e2c3019a2 | |||
| e8ded23a4a | |||
| 21e0714a08 | |||
| 413bbd0c7f | |||
| 32b451ce87 | |||
| b80ad0a892 | |||
| 1ccb4b7adc | |||
| da622a4927 | |||
| 4488ccc689 | |||
| 98329f3b05 | |||
| ecef4f11dc | |||
| 3b9f137061 | |||
| 897fc8c4d4 | |||
| 2758e23381 | |||
| 9d9f5d06a8 | |||
| c589b59dd5 | |||
| dc84431768 | |||
| 0705ea6bae | |||
| c52e2eeb1f | |||
| 58dbe2c027 | |||
| 88c288fdda | |||
| f5e25dcb8d | |||
| e06311a514 | |||
| cb97394211 | |||
| dda51232b9 | |||
| a00cb2d38c | |||
| f92a0fe140 | |||
| b575fe893b | |||
| 54b1b35df5 | |||
| 06a0f742f2 | |||
| 3a5a15fe77 | |||
| 7123326515 | |||
| 4aa52a6b52 | |||
| 9234d80246 | |||
| 10be96814e | |||
| a6d867e4cc | |||
| 4b8de1897b | |||
| 09eaa5b7e3 | |||
| 9df5853942 | |||
| c17e0c94c8 | |||
| b70dda997a | |||
| d10e6aad70 | |||
| e1d58ab635 | |||
| 3b6f981b81 | |||
| d93e31e92a | |||
| 43a91313d8 | |||
| 682811f769 | |||
| cece87f9dc | |||
| e884bb61cb | |||
| 68085ab742 | |||
| 7c210a4855 |
@@ -35,32 +35,29 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: apache/answer
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y qemu-user-static
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
run: |
|
||||
docker buildx create --name answer-builder --driver docker-container --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login --username "${{ secrets.DOCKERHUB_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: ./Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
run: |
|
||||
BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
docker buildx build \
|
||||
--file ./Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag apache/answer:latest \
|
||||
--label org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
--label org.opencontainers.image.revision="${GITHUB_SHA}" \
|
||||
--label org.opencontainers.image.source="https://github.com/${GITHUB_REPOSITORY}" \
|
||||
--label org.opencontainers.image.version="${GITHUB_REF_NAME#v}" \
|
||||
.
|
||||
|
||||
@@ -33,33 +33,29 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: apache/answer
|
||||
tags: |
|
||||
type=ref,enable=true,priority=600,prefix=,suffix=,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y qemu-user-static
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
run: |
|
||||
docker buildx create --name answer-builder --driver docker-container --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login --username "${{ secrets.DOCKERHUB_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: ./Dockerfile
|
||||
tags: apache/answer:${{ inputs.tag_name }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
run: |
|
||||
BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
docker buildx build \
|
||||
--file ./Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag "apache/answer:${{ inputs.tag_name }}" \
|
||||
--label org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
--label org.opencontainers.image.revision="${GITHUB_SHA}" \
|
||||
--label org.opencontainers.image.source="https://github.com/${GITHUB_REPOSITORY}" \
|
||||
--label org.opencontainers.image.version="${{ inputs.tag_name }}" \
|
||||
.
|
||||
|
||||
@@ -34,33 +34,31 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: apache/answer
|
||||
tags: |
|
||||
type=ref,enable=true,priority=600,prefix=,suffix=,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y qemu-user-static
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
run: |
|
||||
docker buildx create --name answer-builder --driver docker-container --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login --username "${{ secrets.DOCKERHUB_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: ./Dockerfile
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
docker buildx build \
|
||||
--file ./Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag "apache/answer:${IMAGE_TAG#v}" \
|
||||
--label org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
--label org.opencontainers.image.revision="${GITHUB_SHA}" \
|
||||
--label org.opencontainers.image.source="https://github.com/${GITHUB_REPOSITORY}" \
|
||||
--label org.opencontainers.image.version="${IMAGE_TAG#v}" \
|
||||
.
|
||||
|
||||
@@ -30,32 +30,23 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: apache/answer
|
||||
tags: |
|
||||
type=raw,value=test
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
- name: Login to DockerHub
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login --username "${{ secrets.DOCKERHUB_USER }}" --password-stdin
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
docker buildx create --name answer-builder --driver docker-container --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
run: |
|
||||
BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
docker buildx build \
|
||||
--file ./Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
--tag apache/answer:test \
|
||||
--label org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
--label org.opencontainers.image.revision="${GITHUB_SHA}" \
|
||||
--label org.opencontainers.image.source="https://github.com/${GITHUB_REPOSITORY}" \
|
||||
.
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
FROM golang:1.24-alpine AS golang-builder
|
||||
FROM golang:1.25-alpine AS golang-builder
|
||||
LABEL maintainer="linkinstar@apache.org"
|
||||
|
||||
ARG GOPROXY
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.PHONY: build clean ui
|
||||
|
||||
VERSION=2.0.1
|
||||
VERSION=2.0.2
|
||||
BIN=answer
|
||||
DIR_SRC=./cmd/answer
|
||||
DOCKER_CMD=docker
|
||||
|
||||
@@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache
|
||||
### Running with docker
|
||||
|
||||
```bash
|
||||
docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.1
|
||||
docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.2
|
||||
```
|
||||
|
||||
For more information, see [Installation](https://answer.apache.org/docs/installation).
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
|
||||
notificationRepo := notification2.NewNotificationRepo(dataData)
|
||||
pluginUserConfigRepo := plugin_config.NewPluginUserConfigRepo(dataData)
|
||||
badgeAwardRepo := badge_award.NewBadgeAwardRepo(dataData, uniqueIDRepo)
|
||||
userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon, userActiveActivityRepo, siteInfoCommonService, emailService, questionRepo, answerRepo, commentCommonRepo, userExternalLoginRepo, notificationRepo, pluginUserConfigRepo, badgeAwardRepo)
|
||||
userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon, userActiveActivityRepo, siteInfoCommonService, emailService, questionRepo, answerRepo, commentCommonRepo, userExternalLoginRepo, notificationRepo, pluginUserConfigRepo, badgeAwardRepo, apiKeyRepo)
|
||||
userAdminController := controller_admin.NewUserAdminController(userAdminService)
|
||||
reasonRepo := reason.NewReasonRepo(configService)
|
||||
reasonService := reason2.NewReasonService(reasonRepo)
|
||||
|
||||
@@ -8781,6 +8781,9 @@ const docTemplate = `{
|
||||
"helpful": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reasoning_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12078,6 +12081,9 @@ const docTemplate = `{
|
||||
},
|
||||
"schema.SiteLoginReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"require_email_verification"
|
||||
],
|
||||
"properties": {
|
||||
"allow_email_domains": {
|
||||
"type": "array",
|
||||
@@ -12093,6 +12099,9 @@ const docTemplate = `{
|
||||
},
|
||||
"allow_password_login": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"require_email_verification": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12113,6 +12122,9 @@ const docTemplate = `{
|
||||
},
|
||||
"allow_password_login": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"require_email_verification": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+38
-32
@@ -214,11 +214,12 @@ Apache 2.0 licenses
|
||||
|
||||
The following components are provided under the Apache 2.0 License.
|
||||
|
||||
(Apache License, Version 2.0) react-helmet-async (https://github.com/staylor/react-helmet-async) [link](./licenses/LICENSE-staylor-react-helmet-async.txt)
|
||||
(Apache License, Version 2.0) golang-mock (https://github.com/golang/mock) [link](./licenses/LICENSE-golang-mock.txt)
|
||||
(Apache License, Version 2.0) gomock (https://github.com/uber-go/mock) [link](./licenses/LICENSE-uber-go-mock.txt)
|
||||
(Apache License, Version 2.0) google-wire (https://github.com/google/wire) [link](./licenses/LICENSE-google-wire.txt)
|
||||
(Apache License, Version 2.0) mojocn-base64Captcha (https://github.com/mojocn/base64Captcha) [link](./licenses/LICENSE-mojocn-base64Captcha.txt)
|
||||
(Apache License, Version 2.0) ory-dockertest (https://github.com/ory/dockertest) [link](./licenses/LICENSE-ory-dockertest.txt)
|
||||
(Apache License, Version 2.0) react-helmet-async (https://github.com/staylor/react-helmet-async) [link](./licenses/LICENSE-staylor-react-helmet-async.txt)
|
||||
(Apache License, Version 2.0) sashabaranov-go-openai (https://github.com/sashabaranov/go-openai) [link](./licenses/LICENSE-sashabaranov-go-openai.txt)
|
||||
(Apache License, Version 2.0) spf13-cobra (https://github.com/spf13/cobra) [link](./licenses/LICENSE-spf13-cobra.txt)
|
||||
|
||||
========================================================================
|
||||
@@ -227,57 +228,62 @@ MIT licenses
|
||||
|
||||
The following components are provided under the MIT License. See project link for details.
|
||||
|
||||
(MIT License) axios (https://github.com/axios/axios) [link](./licenses/LICENSE-axios-axios.txt)
|
||||
(MIT License) bootstrap (https://github.com/twbs/bootstrap) [link](./licenses/LICENSE-twbs-bootstrap.txt)
|
||||
(MIT License) icons (https://github.com/twbs/icons) [link](./licenses/LICENSE-twbs-icons.txt)
|
||||
(MIT License) classnames (https://github.com/JedWatson/classnames) [link](./LICENSE-JedWatson-classnames.txt)
|
||||
(MIT License) codemirror (https://github.com/codemirror/basic-setup) [link](./licenses/LICENSE-codemirror-basic-setup.txt)
|
||||
(MIT License) @codemirror/lang-markdown (https://github.com/codemirror/lang-markdown) [link](./licenses/LICENSE-codemirror-lang-markdown.txt)
|
||||
(MIT License) @codemirror/language-data (https://github.com/codemirror/language-data) [link](./licenses/LICENSE-codemirror-language-data.txt)
|
||||
(MIT License) @codemirror/state (https://github.com/codemirror/state) [link](./licenses/LICENSE-codemirror-state.txt)
|
||||
(MIT License) @codemirror/view (https://github.com/codemirror/view) [link](./licenses/LICENSE-codemirror-view.txt)
|
||||
(MIT License) anargu-gin-brotli (https://github.com/anargu/gin-brotli) [link](./licenses/LICENSE-anargu-gin-brotli.txt)
|
||||
(MIT License) asaskevich-govalidator (https://github.com/asaskevich/govalidator) [link](./licenses/LICENSE-asaskevich-govalidator.txt)
|
||||
(MIT License) axios (https://github.com/axios/axios) [link](./licenses/LICENSE-axios-axios.txt)
|
||||
(MIT License) bootstrap (https://github.com/twbs/bootstrap) [link](./licenses/LICENSE-twbs-bootstrap.txt)
|
||||
(MIT License) classnames (https://github.com/JedWatson/classnames) [link](./LICENSE-JedWatson-classnames.txt)
|
||||
(MIT License) codemirror (https://github.com/codemirror/basic-setup) [link](./licenses/LICENSE-codemirror-basic-setup.txt)
|
||||
(MIT License) color (https://github.com/Qix-/color) [link](./licenses/LICENSE-Qix--color.txt)
|
||||
(MIT License) copy-to-clipboard (https://github.com/sudodoki/copy-to-clipboard) [link](./licenses/LICENSE-sudodoki-copy-to-clipboard.txt)
|
||||
(MIT License) dayjs (https://github.com/iamkun/dayjs) [link](./licenses/LICENSE-iamkun-dayjs.txt)
|
||||
(MIT License) disintegration-imaging (https://github.com/disintegration/imaging) [link](./licenses/LICENSE-disintegration-imaging.txt)
|
||||
(MIT License) front-matter (https://github.com/jxson/front-matter) [link](./licenses/LICENSE-jxson-front-matter.txt)
|
||||
(MIT License) gin-gonic-gin (https://github.com/gin-gonic/gin) [link](./licenses/LICENSE-gin-gonic-gin.txt)
|
||||
(MIT License) go-gomail-gomail (https://gopkg.in/gomail.v2) [link](./licenses/LICENSE-go-gomail-gomail.txt)
|
||||
(MIT License) go-playground-locales (https://github.com/go-playground/locales) [link](./licenses/LICENSE-go-playground-locales.txt)
|
||||
(MIT License) go-playground-universal-translator (https://github.com/go-playground/universal-translator) [link](./licenses/LICENSE-go-playground-universal-translator.txt)
|
||||
(MIT License) go-playground-validator (https://github.com/go-playground/validator) [link](./licenses/LICENSE-go-playground-validator.txt)
|
||||
(MIT License) go-resty-resty (https://github.com/go-resty/resty) [link](./licenses/LICENSE-go-resty-resty.txt)
|
||||
(MIT License) goccy-go-json (https://github.com/goccy/go-json) [link](./licenses/LICENSE-goccy-go-json.txt)
|
||||
(MIT License) i18next (https://github.com/i18next/i18next) [link](./licenses/LICENSE-i18next-i18next.txt)
|
||||
(MIT License) icons (https://github.com/twbs/icons) [link](./licenses/LICENSE-twbs-icons.txt)
|
||||
(MIT License) jinzhu-copier (https://github.com/jinzhu/copier) [link](./licenses/LICENSE-jinzhu-copier.txt)
|
||||
(MIT License) jinzhu-now (https://github.com/jinzhu/now) [link](./licenses/LICENSE-jinzhu-now.txt)
|
||||
(MIT License) joho-godotenv (https://github.com/joho/godotenv) [link](./licenses/LICENSE-joho-godotenv.txt)
|
||||
(MIT License) jordan-wright-email (https://github.com/jordan-wright/email) [link](./licenses/LICENSE-jordan-wright-email.txt)
|
||||
(MIT License) js-sha256 (https://github.com/emn178/js-sha256) [link](./licenses/LICENSE-emn178-js-sha256.txt)
|
||||
(MIT License) lib-pq (https://github.com/lib/pq) [link](./licenses/LICENSE-lib-pq.txt)
|
||||
(MIT License) lodash (https://github.com/lodash/lodash) [link](./licenses/LICENSE-lodash-lodash.txt)
|
||||
(MIT License) Machiel-slugify (https://github.com/Machiel/slugify) [link](./licenses/LICENSE-Machiel-slugify.txt)
|
||||
(MIT License) mark3labs-mcp-go (https://github.com/mark3labs/mcp-go) [link](./licenses/LICENSE-mark3labs-mcp-go.txt)
|
||||
(MIT License) marked (https://github.com/markedjs/marked) [link](./licenses/LICENSE-markedjs-marked.txt)
|
||||
(MIT License) Masterminds-semver (https://github.com/Masterminds/semver) [link](./licenses/LICENSE-Masterminds-semver.txt)
|
||||
(MIT License) mattn-go-sqlite3 (https://github.com/mattn/go-sqlite3) [link](./licenses/LICENSE-mattn-go-sqlite3.txt)
|
||||
(MIT License) mozillazg-go-pinyin (https://github.com/mozillazg/go-pinyin) [link](./licenses/LICENSE-mozillazg-go-pinyin.txt)
|
||||
(MIT License) mozillazg-go-unidecode (https://github.com/mozillazg/go-unidecode) [link](./licenses/LICENSE-mozillazg-go-unidecode.txt)
|
||||
(MIT License) next-share (https://github.com/Bunlong/next-share) [link](./licenses/LIcENSE-Bunlong-next-share.txt)
|
||||
(MIT License) node-qrcode (https://github.com/soldair/node-qrcode) [link](./licenses/LICENSE-soldair-qrcode.txt)
|
||||
(MIT License) react (https://github.com/facebook/react) [link](./licenses/LICENSE-facebook-react.txt)
|
||||
(MIT License) react-bootstrap (https://github.com/react-bootstrap/react-bootstrap) [link](./licenses/LICENSE-react-bootstrap-react-bootstrap.txt)
|
||||
(MIT License) react-i18next (https://github.com/i18next/react-i18next) [link](./licenses/LICENSE-i18next-react-i18next.txt)
|
||||
(MIT License) react-router (https://github.com/remix-run/react-router) [link](./licenses/LICENSE-remix-run-react-router.txt)
|
||||
(MIT License) swr (https://github.com/vercel/swr) [link](./licenses/LICENSE-vercel-swr.txt)
|
||||
(MIT License) zustand (https://github.com/pmndrs/zustand) [link](./licenses/LICENSE-pmndrs-zustand.txt)
|
||||
(MIT License) mozillazg-go-pinyin (https://github.com/mozillazg/go-pinyin) [link](./licenses/LICENSE-mozillazg-go-pinyin.txt)
|
||||
(MIT License) Machiel-slugify (https://github.com/Machiel/slugify) [link](./licenses/LICENSE-Machiel-slugify.txt)
|
||||
(MIT License) Masterminds-semver (https://github.com/Masterminds/semver) [link](./licenses/LICENSE-Masterminds-semver.txt)
|
||||
(MIT License) anargu-gin-brotli (https://github.com/anargu/gin-brotli) [link](./licenses/LICENSE-anargu-gin-brotli.txt)
|
||||
(MIT License) asaskevich-govalidator (https://github.com/asaskevich/govalidator) [link](./licenses/LICENSE-asaskevich-govalidator.txt)
|
||||
(MIT License) disintegration-imaging (https://github.com/disintegration/imaging) [link](./licenses/LICENSE-disintegration-imaging.txt)
|
||||
(MIT License) gin-gonic-gin (https://github.com/gin-gonic/gin) [link](./licenses/LICENSE-gin-gonic-gin.txt)
|
||||
(MIT License) go-playground-locales (https://github.com/go-playground/locales) [link](./licenses/LICENSE-go-playground-locales.txt)
|
||||
(MIT License) go-playground-universal-translator (https://github.com/go-playground/universal-translator) [link](./licenses/LICENSE-go-playground-universal-translator.txt)
|
||||
(MIT License) go-playground-validator (https://github.com/go-playground/validator) [link](./licenses/LICENSE-go-playground-validator.txt)
|
||||
(MIT License) goccy-go-json (https://github.com/goccy/go-json) [link](./licenses/LICENSE-goccy-go-json.txt)
|
||||
(MIT License) jinzhu-copier (https://github.com/jinzhu/copier) [link](./licenses/LICENSE-jinzhu-copier.txt)
|
||||
(MIT License) jinzhu-now (https://github.com/jinzhu/now) [link](./licenses/LICENSE-jinzhu-now.txt)
|
||||
(MIT License) jordan-wright-email (https://github.com/jordan-wright/email) [link](./licenses/LICENSE-jordan-wright-email.txt)
|
||||
(MIT License) lib-pq (https://github.com/lib/pq) [link](./licenses/LICENSE-lib-pq.txt)
|
||||
(MIT License) mattn-go-sqlite3 (https://github.com/mattn/go-sqlite3) [link](./licenses/LICENSE-mattn-go-sqlite3.txt)
|
||||
(MIT License) segmentfault-pacman (https://github.com/segmentfault/pacman) [link](./licenses/LICENSE-segmentfault-pacman.txt)
|
||||
(MIT License) robfig-cron (https://github.com/robfig/cron) [link](./licenses/LICENSE-robfig-cron.txt)
|
||||
(MIT License) scottleedavis-go-exif-remove (https://github.com/scottleedavis/go-exif-remove) [link](./licenses/LICENSE-scottleedavis-go-exif-remove.txt)
|
||||
(MIT License) segmentfault-pacman (https://github.com/segmentfault/pacman) [link](./licenses/LICENSE-segmentfault-pacman.txt)
|
||||
(MIT License) stretchr-testify (https://github.com/stretchr/testify) [link](./licenses/LICENSE-stretchr-testify.txt)
|
||||
(MIT License) swaggo-files (https://github.com/swaggo/files) [link](./licenses/LICENSE-swaggo-files.txt)
|
||||
(MIT License) swaggo-gin-swagger (https://github.com/swaggo/gin-swagger) [link](./licenses/LICENSE-swaggo-gin-swagger.txt)
|
||||
(MIT License) swaggo-swag (https://github.com/swaggo/swag) [link](./licenses/LICENSE-swaggo-swag.txt)
|
||||
(MIT License) swr (https://github.com/vercel/swr) [link](./licenses/LICENSE-vercel-swr.txt)
|
||||
(MIT License) tidwall-gjson (https://github.com/tidwall/gjson) [link](./licenses/LICENSE-tidwall-gjson.txt)
|
||||
(MIT License) uuidjs-uuid (https://github.com/uuidjs/uuid) [link](./licenses/LICENSE-uuidjs-uuid.txt)
|
||||
(MIT License) yuin-goldmark (https://github.com/yuin/goldmark) [link](./licenses/LICENSE-yuin-goldmark.txt)
|
||||
(MIT License) go-gomail-gomail (https://gopkg.in/gomail.v2) [link](./licenses/LICENSE-go-gomail-gomail.txt)
|
||||
(MIT License) front-matter (https://github.com/jxson/front-matter) [link](./licenses/LICENSE-jxson-front-matter.txt)
|
||||
(MIT License) js-sha256 (https://github.com/emn178/js-sha256) [link](./licenses/LICENSE-emn178-js-sha256.txt)
|
||||
(MIT License) zustand (https://github.com/pmndrs/zustand) [link](./licenses/LICENSE-pmndrs-zustand.txt)
|
||||
|
||||
========================================================================
|
||||
BSD licenses
|
||||
@@ -286,13 +292,13 @@ BSD licenses
|
||||
The following components are provided under a BSD license. See project link for details.
|
||||
|
||||
(BSD 2-Clause) bwmarrin-snowflake (https://github.com/bwmarrin/snowflake) [link](./licenses/LICENSE-bwmarrin-snowflake.txt)
|
||||
(BSD 2-Clause) xorm (https://xorm.io/xorm) [link](./licenses/LICENSE-xorm.txt)
|
||||
(BSD 3-Clause) cznic-sqlite (https://modernc.org/sqlite) [link](./licenses/LICENSE-cznic-sqlite.txt)
|
||||
(BSD 3-Clause) google-uuid (https://github.com/google/uuid) [link](./licenses/LICENSE-google-uuid.txt)
|
||||
(BSD 3-Clause) grokify-html-strip-tags-go (https://github.com/grokify/html-strip-tags-go) [link](./licenses/LICENSE-grokify-html-strip-tags-go.txt)
|
||||
(BSD 3-Clause) microcosm-cc-bluemonday (https://github.com/microcosm-cc/bluemonday) [link](./licenses/LICENSE-microcosm-cc-bluemonday.txt)
|
||||
(BSD 3-Clause) cznic-sqlite (https://modernc.org/sqlite) [link](./licenses/LICENSE-cznic-sqlite.txt)
|
||||
(BSD 3-Clause) jsdiff (https://github.com/kpdecker/jsdiff) [link](./licenses/LICENSE-kpdecker-jsdiff.txt)
|
||||
(BSD 3-Clause) microcosm-cc-bluemonday (https://github.com/microcosm-cc/bluemonday) [link](./licenses/LICENSE-microcosm-cc-bluemonday.txt)
|
||||
(BSD 3-Clause) qs (https://github.com/ljharb/qs) [link](./licenses/LICENSE-ljharb-qs.txt)
|
||||
(BSD 2-Clause) xorm (https://xorm.io/xorm) [link](./licenses/LICENSE-xorm.txt)
|
||||
|
||||
========================================================================
|
||||
ISC licenses
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 mozillazg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -8754,6 +8754,9 @@
|
||||
"helpful": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reasoning_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12051,6 +12054,9 @@
|
||||
},
|
||||
"schema.SiteLoginReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"require_email_verification"
|
||||
],
|
||||
"properties": {
|
||||
"allow_email_domains": {
|
||||
"type": "array",
|
||||
@@ -12066,6 +12072,9 @@
|
||||
},
|
||||
"allow_password_login": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"require_email_verification": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12086,6 +12095,9 @@
|
||||
},
|
||||
"allow_password_login": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"require_email_verification": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -228,6 +228,8 @@ definitions:
|
||||
type: integer
|
||||
helpful:
|
||||
type: integer
|
||||
reasoning_content:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
unhelpful:
|
||||
@@ -2514,6 +2516,10 @@ definitions:
|
||||
type: boolean
|
||||
allow_password_login:
|
||||
type: boolean
|
||||
require_email_verification:
|
||||
type: boolean
|
||||
required:
|
||||
- require_email_verification
|
||||
type: object
|
||||
schema.SiteLoginResp:
|
||||
properties:
|
||||
@@ -2527,6 +2533,8 @@ definitions:
|
||||
type: boolean
|
||||
allow_password_login:
|
||||
type: boolean
|
||||
require_email_verification:
|
||||
type: boolean
|
||||
type: object
|
||||
schema.SiteMCPReq:
|
||||
properties:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
module github.com/apache/answer
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Machiel/slugify v1.0.1
|
||||
@@ -43,6 +43,7 @@ require (
|
||||
github.com/mark3labs/mcp-go v0.43.2
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mozillazg/go-pinyin v0.20.0
|
||||
github.com/mozillazg/go-unidecode v0.2.0
|
||||
github.com/ory/dockertest/v3 v3.11.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sashabaranov/go-openai v1.41.2
|
||||
@@ -61,11 +62,10 @@ require (
|
||||
github.com/tidwall/gjson v1.17.3
|
||||
github.com/yuin/goldmark v1.7.4
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.20.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/text v0.28.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.39.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.0
|
||||
@@ -170,8 +170,9 @@ require (
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/arch v0.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
@@ -462,6 +462,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
|
||||
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||
github.com/mozillazg/go-unidecode v0.2.0 h1:vFGEzAH9KSwyWmXCOblazEWDh7fOkpmy/Z4ArmamSUc=
|
||||
github.com/mozillazg/go-unidecode v0.2.0/go.mod h1:zB48+/Z5toiRolOZy9ksLryJ976VIwmDmpQ2quyt1aA=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
|
||||
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
|
||||
@@ -703,8 +705,8 @@ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWP
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
@@ -721,8 +723,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
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=
|
||||
@@ -749,8 +751,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -761,8 +763,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -797,14 +799,14 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -813,8 +815,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
@@ -840,8 +842,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
+6
-1
@@ -867,6 +867,8 @@ ui:
|
||||
copy: Copy
|
||||
ask_a_follow_up: Ask a follow-up
|
||||
ask_placeholder: Ask a question
|
||||
thinking: Thinking…
|
||||
thoughts: Thoughts
|
||||
notifications:
|
||||
title: Notifications
|
||||
inbox: Inbox
|
||||
@@ -2239,6 +2241,10 @@ ui:
|
||||
title: Email registration
|
||||
label: Allow email registration
|
||||
text: Turn off to prevent anyone creating new account through email.
|
||||
email_verification:
|
||||
title: Email verification
|
||||
label: Require email verification
|
||||
text: When enabled, users must verify their email address before using the site.
|
||||
allowed_email_domains:
|
||||
title: Allowed email domains
|
||||
text: Email domains that users must register accounts with. One domain per line. Ignored when empty.
|
||||
@@ -2483,4 +2489,3 @@ ui:
|
||||
copied: Copied
|
||||
external_content_warning: External images/media are not displayed.
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ language_options:
|
||||
progress: 96
|
||||
- label: "Русский"
|
||||
value: "ru_RU"
|
||||
progress: 80
|
||||
progress: 100
|
||||
- label: "简体中文"
|
||||
value: "zh_CN"
|
||||
progress: 100
|
||||
|
||||
+400
-397
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,9 @@ const (
|
||||
ConfigCacheTime = 1 * time.Hour
|
||||
ConnectorUserExternalInfoCacheKey = "answer:connector:"
|
||||
ConnectorUserExternalInfoCacheTime = 10 * time.Minute
|
||||
ConnectorOAuthStateCacheKey = "answer:connector:oauth-state:"
|
||||
ConnectorOAuthStateCacheTime = 10 * time.Minute
|
||||
ConnectorOAuthBindStateCacheTime = 5 * time.Minute
|
||||
SiteMapQuestionCacheKeyPrefix = "answer:sitemap:question:%d"
|
||||
SiteMapQuestionCacheTime = time.Hour
|
||||
SitemapMaxSize = 50000
|
||||
|
||||
@@ -29,10 +29,16 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const maxAcceptLanguageLength = 256
|
||||
|
||||
// ExtractAndSetAcceptLanguage extract accept language from header and set to context
|
||||
func ExtractAndSetAcceptLanguage(ctx *gin.Context) {
|
||||
// The language of our front-end configuration, like en_US
|
||||
acceptLanguage := ctx.GetHeader(constant.AcceptLanguageFlag)
|
||||
if len(acceptLanguage) > maxAcceptLanguageLength {
|
||||
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
|
||||
return
|
||||
}
|
||||
tag, _, err := language.ParseAcceptLanguage(acceptLanguage)
|
||||
if err != nil || len(tag) == 0 {
|
||||
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
|
||||
|
||||
@@ -25,8 +25,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const contentSecurityPolicy = "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http: https:; font-src 'self' data:; connect-src 'self'"
|
||||
|
||||
func HeadersByRequestURI() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Security-Policy", contentSecurityPolicy)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
|
||||
c.Header("cache-control", "public, max-age=31536000")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestHeadersByRequestURI(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(HeadersByRequestURI())
|
||||
router.GET("/", func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) })
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
|
||||
if got := response.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
if got := response.Header().Get("Content-Security-Policy"); got != contentSecurityPolicy {
|
||||
t.Fatalf("Content-Security-Policy = %q, want %q", got, contentSecurityPolicy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
func Recovery(apiPrefixes ...string) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Errorf("panic recovered: %v\n%s", err, debug.Stack())
|
||||
|
||||
// Headers/body already flushed (SSE or any streamed response).
|
||||
// We can no longer rewrite the response cleanly; just stop the chain.
|
||||
if ctx.Writer.Written() {
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
path := ctx.Request.URL.Path
|
||||
for _, p := range apiPrefixes {
|
||||
if strings.HasPrefix(path, p) {
|
||||
ctx.AbortWithStatusJSON(http.StatusInternalServerError,
|
||||
handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError).
|
||||
TrMsg(handler.GetLangByCtx(ctx)),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Panic on an API path returns the project's unified JSON 500.
|
||||
func TestRecovery_APIPathPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/api/panic", func(ctx *gin.Context) {
|
||||
panic("test panic")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/panic", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if body["reason"] != "base.unknown" {
|
||||
t.Errorf("unexpected reason: %v", body["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
// Panic on a non-API path returns a bare 500 with no body, so the browser can
|
||||
// render its own error page instead of showing raw JSON.
|
||||
func TestRecovery_NonAPIPathPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/page", func(ctx *gin.Context) {
|
||||
panic("test panic")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/page", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body for non-API path, got: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Panic after the response has already started writing (SSE / streamed
|
||||
// responses). The middleware must not touch the response — status and body
|
||||
// already on the wire stay untouched, no JSON gets appended.
|
||||
func TestRecovery_PanicAfterResponseStarted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/api/stream", func(ctx *gin.Context) {
|
||||
ctx.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Writer.Write([]byte("partial data"))
|
||||
panic("test panic after write")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/stream", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status to remain 200 (already flushed), got %d", w.Code)
|
||||
}
|
||||
if w.Body.String() != "partial data" {
|
||||
t.Errorf("expected body to remain 'partial data' (no error JSON appended), got: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Normal requests pass through unaffected.
|
||||
func TestRecovery_NoPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/api/ok", func(ctx *gin.Context) {
|
||||
ctx.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/ok", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func (am *AuthUserMiddleware) VisitAuth() gin.HandlerFunc {
|
||||
|
||||
siteSecurity, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !siteSecurity.LoginRequired {
|
||||
|
||||
@@ -102,13 +102,11 @@ func (q *Queue[T]) Close() {
|
||||
|
||||
// startWorker starts the background goroutine that processes messages.
|
||||
func (q *Queue[T]) startWorker() {
|
||||
q.wg.Add(1)
|
||||
go func() {
|
||||
defer q.wg.Done()
|
||||
q.wg.Go(func() {
|
||||
for msg := range q.queue {
|
||||
q.processMessage(msg)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// processMessage handles a single message with proper synchronization.
|
||||
|
||||
@@ -200,13 +200,11 @@ func TestQueue_ConcurrentRegisterHandler(t *testing.T) {
|
||||
// Concurrently register handlers - should not race
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
wg.Go(func() {
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ func NewHTTPServer(debug bool,
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(middleware.Recovery(
|
||||
uiConf.APIBaseURL+"/answer/api/v1",
|
||||
uiConf.APIBaseURL+"/answer/admin/api",
|
||||
))
|
||||
r.Use(func(ctx *gin.Context) {
|
||||
if strings.Contains(ctx.Request.URL.Path, "/chat/completions") {
|
||||
return
|
||||
@@ -74,7 +78,7 @@ func NewHTTPServer(debug bool,
|
||||
rootGroup := r.Group("")
|
||||
swaggerRouter.Register(rootGroup)
|
||||
static := r.Group(uiConf.APIBaseURL)
|
||||
static.Use(avatarMiddleware.AvatarThumb(), authUserMiddleware.VisitAuth())
|
||||
static.Use(authUserMiddleware.VisitAuth(), avatarMiddleware.AvatarThumb())
|
||||
staticRouter.RegisterStaticRouter(static)
|
||||
|
||||
// The route must be available without logging in
|
||||
|
||||
@@ -143,8 +143,9 @@ type StreamChoice struct {
|
||||
}
|
||||
|
||||
type Delta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
@@ -443,14 +444,15 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
toolCalls, newMessages, finished, aiResponse := c.processAIStream(ctx, w, id, conversationCtx.Model, client, aiReq, messages)
|
||||
toolCalls, newMessages, finished, aiResponse, reasoningContent := c.processAIStream(ctx, w, id, conversationCtx.Model, client, aiReq, messages)
|
||||
messages = newMessages
|
||||
|
||||
log.Debugf("Round %d: toolCalls=%v", round+1, toolCalls)
|
||||
if aiResponse != "" {
|
||||
if aiResponse != "" || reasoningContent != "" {
|
||||
conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{
|
||||
Role: "assistant",
|
||||
Content: aiResponse,
|
||||
Role: "assistant",
|
||||
Content: aiResponse,
|
||||
ReasoningContent: reasoningContent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -459,7 +461,7 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri
|
||||
}
|
||||
|
||||
if len(toolCalls) > 0 {
|
||||
messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages)
|
||||
messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages, aiResponse, reasoningContent)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@@ -471,12 +473,12 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri
|
||||
// processAIStream
|
||||
func (c *AIController) processAIStream(
|
||||
_ *gin.Context, w http.ResponseWriter, id, model string, client *openai.Client, aiReq openai.ChatCompletionRequest, messages []openai.ChatCompletionMessage) (
|
||||
[]openai.ToolCall, []openai.ChatCompletionMessage, bool, string) {
|
||||
[]openai.ToolCall, []openai.ChatCompletionMessage, bool, string, string) {
|
||||
stream, err := client.CreateChatCompletionStream(context.Background(), aiReq)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create stream: %v", err)
|
||||
c.sendErrorResponse(w, id, model, "Failed to create AI stream")
|
||||
return nil, messages, true, ""
|
||||
return nil, messages, true, "", ""
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.Close()
|
||||
@@ -484,6 +486,7 @@ func (c *AIController) processAIStream(
|
||||
|
||||
var currentToolCalls []openai.ToolCall
|
||||
var accumulatedContent strings.Builder
|
||||
var accumulatedReasoning strings.Builder
|
||||
var accumulatedMessage openai.ChatCompletionMessage
|
||||
toolCallsMap := make(map[int]*openai.ToolCall)
|
||||
|
||||
@@ -528,6 +531,27 @@ func (c *AIController) processAIStream(
|
||||
}
|
||||
}
|
||||
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
accumulatedReasoning.WriteString(choice.Delta.ReasoningContent)
|
||||
|
||||
reasoningResponse := StreamResponse{
|
||||
ChatCompletionID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: time.Now().Unix(),
|
||||
Model: model,
|
||||
Choices: []StreamChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: Delta{
|
||||
ReasoningContent: choice.Delta.ReasoningContent,
|
||||
},
|
||||
FinishReason: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
sendStreamData(w, reasoningResponse)
|
||||
}
|
||||
|
||||
if choice.Delta.Content != "" {
|
||||
accumulatedContent.WriteString(choice.Delta.Content)
|
||||
|
||||
@@ -554,26 +578,30 @@ func (c *AIController) processAIStream(
|
||||
for _, toolCall := range toolCallsMap {
|
||||
currentToolCalls = append(currentToolCalls, *toolCall)
|
||||
}
|
||||
return currentToolCalls, messages, false, accumulatedContent.String()
|
||||
return currentToolCalls, messages, false, accumulatedContent.String(), accumulatedReasoning.String()
|
||||
} else {
|
||||
aiResponseContent := accumulatedContent.String()
|
||||
if aiResponseContent != "" {
|
||||
aiReasoningContent := accumulatedReasoning.String()
|
||||
if aiResponseContent != "" || aiReasoningContent != "" {
|
||||
accumulatedMessage = openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleAssistant,
|
||||
Content: aiResponseContent,
|
||||
Role: openai.ChatMessageRoleAssistant,
|
||||
Content: aiResponseContent,
|
||||
ReasoningContent: aiReasoningContent,
|
||||
}
|
||||
messages = append(messages, accumulatedMessage)
|
||||
}
|
||||
return nil, messages, true, aiResponseContent
|
||||
return nil, messages, true, aiResponseContent, aiReasoningContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aiResponseContent := accumulatedContent.String()
|
||||
if aiResponseContent != "" {
|
||||
aiReasoningContent := accumulatedReasoning.String()
|
||||
if aiResponseContent != "" || aiReasoningContent != "" {
|
||||
accumulatedMessage = openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleAssistant,
|
||||
Content: aiResponseContent,
|
||||
Role: openai.ChatMessageRoleAssistant,
|
||||
Content: aiResponseContent,
|
||||
ReasoningContent: aiReasoningContent,
|
||||
}
|
||||
messages = append(messages, accumulatedMessage)
|
||||
}
|
||||
@@ -582,14 +610,14 @@ func (c *AIController) processAIStream(
|
||||
for _, toolCall := range toolCallsMap {
|
||||
currentToolCalls = append(currentToolCalls, *toolCall)
|
||||
}
|
||||
return currentToolCalls, messages, false, aiResponseContent
|
||||
return currentToolCalls, messages, false, aiResponseContent, aiReasoningContent
|
||||
}
|
||||
|
||||
return currentToolCalls, messages, len(currentToolCalls) == 0, aiResponseContent
|
||||
return currentToolCalls, messages, len(currentToolCalls) == 0, aiResponseContent, aiReasoningContent
|
||||
}
|
||||
|
||||
// executeToolCalls
|
||||
func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, _, _ string, toolCalls []openai.ToolCall, messages []openai.ChatCompletionMessage) []openai.ChatCompletionMessage {
|
||||
func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, _, _ string, toolCalls []openai.ToolCall, messages []openai.ChatCompletionMessage, assistantContent, reasoningContent string) []openai.ChatCompletionMessage {
|
||||
validToolCalls := make([]openai.ToolCall, 0)
|
||||
for _, toolCall := range toolCalls {
|
||||
if toolCall.ID == "" || toolCall.Function.Name == "" {
|
||||
@@ -611,8 +639,10 @@ func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter,
|
||||
}
|
||||
|
||||
assistantMsg := openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleAssistant,
|
||||
ToolCalls: validToolCalls,
|
||||
Role: openai.ChatMessageRoleAssistant,
|
||||
Content: assistantContent,
|
||||
ReasoningContent: reasoningContent,
|
||||
ToolCalls: validToolCalls,
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/middleware"
|
||||
@@ -106,6 +107,27 @@ func (cc *ConnectorController) ConnectorLogin(connector plugin.Connector) (fn fu
|
||||
return
|
||||
}
|
||||
|
||||
state := ctx.Query("state")
|
||||
if len(state) > 0 {
|
||||
stateInfo, err := cc.userExternalService.GetOAuthState(ctx, state)
|
||||
if err != nil || stateInfo == nil || stateInfo.Provider != connector.ConnectorSlugName() {
|
||||
log.Errorf("invalid connector oauth state for provider %s", connector.ConnectorSlugName())
|
||||
ctx.Redirect(http.StatusFound, "/50x")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
state, err = cc.userExternalService.GenerateOAuthState(ctx, connector.ConnectorSlugName(),
|
||||
schema.ExternalLoginOAuthStateLoginIntent, "")
|
||||
if err != nil {
|
||||
log.Errorf("generate connector oauth state failed: %v", err)
|
||||
ctx.Redirect(http.StatusFound, "/50x")
|
||||
return
|
||||
}
|
||||
q := ctx.Request.URL.Query()
|
||||
q.Set("state", state)
|
||||
ctx.Request.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
receiverURL := fmt.Sprintf("%s%s%s%s", general.SiteUrl,
|
||||
commonRouterPrefix, ConnectorRedirectRouterPrefix, connector.ConnectorSlugName())
|
||||
redirectURL := connector.ConnectorSender(ctx, receiverURL)
|
||||
@@ -141,6 +163,27 @@ func (cc *ConnectorController) ConnectorRedirect(connector plugin.Connector) (fn
|
||||
Avatar: userInfo.Avatar,
|
||||
MetaInfo: userInfo.MetaInfo,
|
||||
}
|
||||
stateInfo, err := cc.userExternalService.ConsumeOAuthState(ctx, ctx.Query("state"))
|
||||
if err != nil {
|
||||
log.Errorf("get connector oauth state failed: %v", err)
|
||||
ctx.Redirect(http.StatusFound, "/50x")
|
||||
return
|
||||
}
|
||||
if stateInfo != nil && stateInfo.Provider != connector.ConnectorSlugName() {
|
||||
log.Errorf("connector oauth state provider mismatch: %s != %s",
|
||||
stateInfo.Provider, connector.ConnectorSlugName())
|
||||
ctx.Redirect(http.StatusFound, "/50x")
|
||||
return
|
||||
}
|
||||
if stateInfo != nil && stateInfo.Intent == schema.ExternalLoginOAuthStateBindIntent {
|
||||
if err = cc.userExternalService.BindExternalLoginToUser(ctx, stateInfo.UserID, u); err != nil {
|
||||
log.Errorf("bind external login failed: %v", err)
|
||||
ctx.Redirect(http.StatusFound, "/50x")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/settings/account", siteGeneral.SiteUrl))
|
||||
return
|
||||
}
|
||||
resp, err := cc.userExternalService.ExternalLogin(ctx, u)
|
||||
if err != nil {
|
||||
log.Errorf("external login failed: %v", err)
|
||||
@@ -237,19 +280,32 @@ func (cc *ConnectorController) ConnectorsUserInfo(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
resp := make([]*schema.ConnectorUserInfoResp, 0)
|
||||
_ = plugin.CallConnector(func(fn plugin.Connector) error {
|
||||
err = plugin.CallConnector(func(fn plugin.Connector) error {
|
||||
externalID := userExternalLoginMapping[fn.ConnectorSlugName()]
|
||||
connectorName := fn.ConnectorName()
|
||||
link := fmt.Sprintf("%s%s%s%s", general.SiteUrl,
|
||||
commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName())
|
||||
if len(externalID) == 0 {
|
||||
state, err := cc.userExternalService.GenerateOAuthState(ctx, fn.ConnectorSlugName(),
|
||||
schema.ExternalLoginOAuthStateBindIntent, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link = fmt.Sprintf("%s?state=%s", link, url.QueryEscape(state))
|
||||
}
|
||||
resp = append(resp, &schema.ConnectorUserInfoResp{
|
||||
Name: connectorName.Translate(ctx),
|
||||
Icon: fn.ConnectorLogoSVG(),
|
||||
Link: fmt.Sprintf("%s%s%s%s", general.SiteUrl,
|
||||
commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName()),
|
||||
Name: connectorName.Translate(ctx),
|
||||
Icon: fn.ConnectorLogoSVG(),
|
||||
Link: link,
|
||||
Binding: len(externalID) > 0,
|
||||
ExternalID: externalID,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
handler.HandleResponse(ctx, nil, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, req
|
||||
}
|
||||
|
||||
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
|
||||
if err != nil {
|
||||
if err != nil || !mcpQuestionIsPublic(question) {
|
||||
log.Errorf("get question failed: %v", err)
|
||||
return mcp.NewToolResultText("No question found."), nil
|
||||
}
|
||||
@@ -161,6 +161,9 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchAnswerCond(request)
|
||||
if len(cond.QuestionID) == 0 {
|
||||
return mcp.NewToolResultText("[]"), nil
|
||||
}
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
@@ -169,6 +172,10 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
}
|
||||
|
||||
if len(cond.QuestionID) > 0 {
|
||||
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
|
||||
if err != nil || !mcpQuestionIsPublic(question) {
|
||||
return mcp.NewToolResultText("[]"), nil
|
||||
}
|
||||
answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
|
||||
if err != nil {
|
||||
log.Errorf("get answers failed: %v", err)
|
||||
@@ -176,6 +183,9 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
}
|
||||
resp := make([]*schema.MCPSearchAnswerInfoResp, 0)
|
||||
for _, answer := range answerList {
|
||||
if answer.Status != entity.AnswerStatusAvailable {
|
||||
continue
|
||||
}
|
||||
t := &schema.MCPSearchAnswerInfoResp{
|
||||
QuestionID: answer.QuestionID,
|
||||
AnswerID: answer.ID,
|
||||
@@ -195,6 +205,9 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
}
|
||||
resp := make([]*schema.MCPSearchAnswerInfoResp, 0)
|
||||
for _, answer := range answerList {
|
||||
if answer.Status != entity.AnswerStatusAvailable {
|
||||
continue
|
||||
}
|
||||
t := &schema.MCPSearchAnswerInfoResp{
|
||||
QuestionID: answer.QuestionID,
|
||||
AnswerID: answer.ID,
|
||||
@@ -208,12 +221,33 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
}
|
||||
}
|
||||
|
||||
func mcpQuestionIsPublic(question *schema.QuestionInfoResp) bool {
|
||||
return question != nil && question.Show == entity.QuestionShow &&
|
||||
(question.Status == entity.QuestionStatusAvailable || question.Status == entity.QuestionStatusClosed)
|
||||
}
|
||||
|
||||
func (c *MCPController) mcpObjectQuestionIsPublic(ctx context.Context, objectID string) bool {
|
||||
question, err := c.questioncommon.Info(ctx, objectID, "")
|
||||
if err == nil {
|
||||
return mcpQuestionIsPublic(question)
|
||||
}
|
||||
answer, exist, err := c.answerRepo.GetAnswer(ctx, objectID)
|
||||
if err != nil || !exist || answer.Status != entity.AnswerStatusAvailable {
|
||||
return false
|
||||
}
|
||||
question, err = c.questioncommon.Info(ctx, answer.QuestionID, "")
|
||||
return err == nil && mcpQuestionIsPublic(question)
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchCommentCond(request)
|
||||
if len(cond.ObjectID) == 0 || !c.mcpObjectQuestionIsPublic(ctx, cond.ObjectID) {
|
||||
return mcp.NewToolResultText("No comments found."), nil
|
||||
}
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
func TestMCPQuestionIsPublic(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
question *schema.QuestionInfoResp
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"available", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow}, true},
|
||||
{"closed", &schema.QuestionInfoResp{Status: entity.QuestionStatusClosed, Show: entity.QuestionShow}, true},
|
||||
{"hidden", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide}, false},
|
||||
{"deleted", &schema.QuestionInfoResp{Status: entity.QuestionStatusDeleted, Show: entity.QuestionShow}, false},
|
||||
{"pending", &schema.QuestionInfoResp{Status: entity.QuestionStatusPending, Show: entity.QuestionShow}, false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := mcpQuestionIsPublic(testCase.question); got != testCase.want {
|
||||
t.Fatalf("mcpQuestionIsPublic() = %v, want %v", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -144,13 +144,7 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
req.CanPin = canList[0]
|
||||
req.CanList = canList[1]
|
||||
if (req.Operation == schema.QuestionOperationPin || req.Operation == schema.QuestionOperationUnPin) && !req.CanPin {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
|
||||
return
|
||||
}
|
||||
if (req.Operation == schema.QuestionOperationHide || req.Operation == schema.QuestionOperationShow) && !req.CanList {
|
||||
if !canOperateQuestion(req.Operation, canList) {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
|
||||
return
|
||||
}
|
||||
@@ -158,6 +152,21 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
}
|
||||
|
||||
func canOperateQuestion(operation string, canList []bool) bool {
|
||||
switch operation {
|
||||
case schema.QuestionOperationPin:
|
||||
return canList[0]
|
||||
case schema.QuestionOperationUnPin:
|
||||
return canList[1]
|
||||
case schema.QuestionOperationHide:
|
||||
return canList[2]
|
||||
case schema.QuestionOperationShow:
|
||||
return canList[3]
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// CloseQuestion Close question
|
||||
// @Summary Close question
|
||||
// @Description Close question
|
||||
@@ -233,6 +242,24 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
id := ctx.Query("id")
|
||||
id = uid.DeShortID(id)
|
||||
userID := middleware.GetLoginUserIDFromContext(ctx)
|
||||
req, err := qc.questionPermission(ctx, userID, id)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
if handler.GetEnableShortID(ctx) {
|
||||
info.ID = uid.EnShortID(info.ID)
|
||||
}
|
||||
handler.HandleResponse(ctx, nil, info)
|
||||
}
|
||||
|
||||
func (qc *QuestionController) questionPermission(ctx *gin.Context, userID, questionID string) (schema.QuestionPermission, error) {
|
||||
req := schema.QuestionPermission{}
|
||||
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
|
||||
canList, err := qc.rankService.CheckOperationPermissions(ctx, userID, []string{
|
||||
@@ -248,10 +275,9 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
permission.QuestionUnDelete,
|
||||
})
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
return req, err
|
||||
}
|
||||
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, id)
|
||||
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, questionID)
|
||||
|
||||
req.CanEdit = canList[0] || objectOwner
|
||||
req.CanDelete = canList[1]
|
||||
@@ -263,16 +289,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
req.CanShow = canList[7]
|
||||
req.CanInviteOtherToAnswer = canList[8]
|
||||
req.CanRecover = canList[9]
|
||||
|
||||
info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
if handler.GetEnableShortID(ctx) {
|
||||
info.ID = uid.EnShortID(info.ID)
|
||||
}
|
||||
handler.HandleResponse(ctx, nil, info)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetQuestionInviteUserInfo get question invite user info
|
||||
@@ -286,7 +303,13 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
// @Router /answer/api/v1/question/invite [get]
|
||||
func (qc *QuestionController) GetQuestionInviteUserInfo(ctx *gin.Context) {
|
||||
questionID := uid.DeShortID(ctx.Query("id"))
|
||||
resp, err := qc.questionService.InviteUserInfo(ctx, questionID)
|
||||
userID := middleware.GetLoginUserIDFromContext(ctx)
|
||||
per, err := qc.questionPermission(ctx, userID, questionID)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
resp, err := qc.questionService.InviteUserInfo(ctx, questionID, userID, per)
|
||||
handler.HandleResponse(ctx, err, resp)
|
||||
}
|
||||
|
||||
@@ -989,6 +1012,7 @@ func (qc *QuestionController) GetQuestionLink(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
|
||||
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
|
||||
req.QuestionID = uid.DeShortID(req.QuestionID)
|
||||
questions, total, err := qc.questionService.GetQuestionLink(ctx, req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
func TestCanOperateQuestionUsesMatchingPermission(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
operation string
|
||||
canList []bool
|
||||
want bool
|
||||
}{
|
||||
{"pin", schema.QuestionOperationPin, []bool{true, false, false, false}, true},
|
||||
{"unpin", schema.QuestionOperationUnPin, []bool{false, true, false, false}, true},
|
||||
{"hide", schema.QuestionOperationHide, []bool{false, false, true, false}, true},
|
||||
{"show", schema.QuestionOperationShow, []bool{false, false, false, true}, true},
|
||||
{"unpin does not authorize hide", schema.QuestionOperationHide, []bool{false, true, false, false}, false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := canOperateQuestion(testCase.operation, testCase.canList); got != testCase.want {
|
||||
t.Fatalf("canOperateQuestion(%q, %v) = %v, want %v", testCase.operation, testCase.canList, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,11 @@
|
||||
package templaterender
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/apache/answer/internal/base/pager"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/jinzhu/copier"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func (q *TemplateRenderController) TagList(ctx context.Context, req *schema.GetTagWithPageReq) (resp *pager.PageModel, err error) {
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
package templaterender
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func (q *TemplateRenderController) UserInfo(ctx context.Context, req *schema.GetOtherUserInfoByUsernameReq) (resp *schema.GetOtherUserInfoByUsernameResp, err error) {
|
||||
|
||||
@@ -279,6 +279,7 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
|
||||
handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil)
|
||||
return
|
||||
}
|
||||
req.RequireEmailVerification = siteInfo.RequireEmailVerification
|
||||
req.IP = ctx.ClientIP()
|
||||
isAdmin := middleware.GetUserIsAdminModerator(ctx)
|
||||
if !isAdmin {
|
||||
|
||||
@@ -30,6 +30,7 @@ type AIConversationRecord struct {
|
||||
ChatCompletionID string `xorm:"not null VARCHAR(255) chat_completion_id"`
|
||||
Role string `xorm:"not null default '' VARCHAR(128) role"`
|
||||
Content string `xorm:"not null MEDIUMTEXT content"`
|
||||
ReasoningContent string `xorm:"MEDIUMTEXT reasoning_content"`
|
||||
Helpful int `xorm:"not null default 0 INT(11) helpful"`
|
||||
Unhelpful int `xorm:"not null default 0 INT(11) unhelpful"`
|
||||
}
|
||||
|
||||
@@ -226,9 +226,10 @@ func (m *Mentor) initSiteInfoGeneralData() {
|
||||
|
||||
func (m *Mentor) initSiteInfoLoginConfig() {
|
||||
loginConfig := map[string]any{
|
||||
"allow_new_registrations": true,
|
||||
"allow_email_registrations": true,
|
||||
"allow_password_login": true,
|
||||
"allow_new_registrations": true,
|
||||
"allow_email_registrations": true,
|
||||
"allow_password_login": true,
|
||||
"require_email_verification": true,
|
||||
}
|
||||
loginConfigDataBytes, _ := json.Marshal(loginConfig)
|
||||
_, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{
|
||||
|
||||
@@ -108,6 +108,9 @@ var migrations = []Migration{
|
||||
NewMigration("v1.8.0", "change admin menu", updateAdminMenuSettings, true),
|
||||
NewMigration("v1.8.1", "ai feat", aiFeat, true),
|
||||
NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false),
|
||||
NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false),
|
||||
NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true),
|
||||
NewMigration("v2.0.4", "repair missing advanced site settings", repairAdvancedSiteInfo, true),
|
||||
}
|
||||
|
||||
func GetMigrations() []Migration {
|
||||
|
||||
@@ -302,10 +302,11 @@ func splitLegalMenu(ctx context.Context, x *xorm.Engine) error {
|
||||
PrivacyPolicyParsedText: oldSiteLegal.PrivacyPolicyParsedText,
|
||||
}
|
||||
siteLogin := &schema.SiteLoginResp{
|
||||
AllowNewRegistrations: oldSiteLogin.AllowNewRegistrations,
|
||||
AllowEmailRegistrations: oldSiteLogin.AllowEmailRegistrations,
|
||||
AllowPasswordLogin: oldSiteLogin.AllowPasswordLogin,
|
||||
AllowEmailDomains: oldSiteLogin.AllowEmailDomains,
|
||||
AllowNewRegistrations: oldSiteLogin.AllowNewRegistrations,
|
||||
AllowEmailRegistrations: oldSiteLogin.AllowEmailRegistrations,
|
||||
AllowPasswordLogin: oldSiteLogin.AllowPasswordLogin,
|
||||
AllowEmailDomains: oldSiteLogin.AllowEmailDomains,
|
||||
RequireEmailVerification: true,
|
||||
}
|
||||
siteGeneral := &schema.SiteGeneralReq{
|
||||
Name: oldSiteGeneral.Name,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// addAIConversationReasoningContent adds a reasoning_content column to the
|
||||
// ai_conversation_record table so that the chain-of-thought returned by
|
||||
// reasoning/thinking-capable models (e.g. DeepSeek) is persisted along with
|
||||
// the regular content and can be re-displayed when reloading a conversation.
|
||||
func addAIConversationReasoningContent(ctx context.Context, x *xorm.Engine) error {
|
||||
if err := x.Context(ctx).Sync(new(entity.AIConversationRecord)); err != nil {
|
||||
return fmt.Errorf("sync ai_conversation_record table failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func addRequireEmailVerification(ctx context.Context, x *xorm.Engine) error {
|
||||
loginSiteInfo := &entity.SiteInfo{}
|
||||
exist, err := x.Context(ctx).Where("type = ?", constant.SiteTypeLogin).Get(loginSiteInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get login config failed: %w", err)
|
||||
}
|
||||
if !exist {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := backfillRequireEmailVerification(loginSiteInfo.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfill login config failed: %w", err)
|
||||
}
|
||||
loginSiteInfo.Content = content
|
||||
_, err = x.Context(ctx).ID(loginSiteInfo.ID).Cols("content").Update(loginSiteInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update login config failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillRequireEmailVerification(content string) (string, error) {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = "{}"
|
||||
}
|
||||
|
||||
loginConfig := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(content), &loginConfig); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if loginConfig == nil {
|
||||
loginConfig = map[string]json.RawMessage{}
|
||||
}
|
||||
|
||||
requireEmailVerification, exists := loginConfig["require_email_verification"]
|
||||
// Legacy configs that predate this setting should keep the safer behavior.
|
||||
// Treat a missing or null value as requiring email verification.
|
||||
if !exists || bytes.Equal(bytes.TrimSpace(requireEmailVerification), []byte("null")) {
|
||||
loginConfig["require_email_verification"] = json.RawMessage("true")
|
||||
} else {
|
||||
var value bool
|
||||
if err := json.Unmarshal(requireEmailVerification, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
loginConfig["require_email_verification"] = requireEmailVerification
|
||||
}
|
||||
|
||||
data, err := json.Marshal(loginConfig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func TestBackfillRequireEmailVerification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "adds true for missing key",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "converts null to true",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":null}`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "preserves false",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "preserves true",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":true}`,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
content, err := backfillRequireEmailVerification(tt.content)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]bool
|
||||
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||
assert.Equal(t, tt.expected, result["require_email_verification"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRequireEmailVerificationAbsentLoginRow(t *testing.T) {
|
||||
x, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = x.Close()
|
||||
}()
|
||||
require.NoError(t, x.Sync(new(entity.SiteInfo)))
|
||||
|
||||
require.NoError(t, addRequireEmailVerification(context.TODO(), x))
|
||||
}
|
||||
|
||||
func TestAddRequireEmailVerificationUpdatesLoginRow(t *testing.T) {
|
||||
x, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = x.Close()
|
||||
}()
|
||||
require.NoError(t, x.Sync(new(entity.SiteInfo)))
|
||||
|
||||
_, err = x.Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeLogin,
|
||||
Content: `{"allow_new_registrations":true}`,
|
||||
Status: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, addRequireEmailVerification(context.TODO(), x))
|
||||
|
||||
login := &entity.SiteInfo{}
|
||||
exist, err := x.Where("type = ?", constant.SiteTypeLogin).Get(login)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exist)
|
||||
|
||||
var result struct {
|
||||
RequireEmailVerification bool `json:"require_email_verification"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(login.Content), &result))
|
||||
assert.True(t, result.RequireEmailVerification)
|
||||
}
|
||||
|
||||
func TestSplitLegalMenuKeepsRequireEmailVerificationDefaultTrue(t *testing.T) {
|
||||
x, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = x.Close()
|
||||
}()
|
||||
require.NoError(t, x.Sync(new(entity.SiteInfo)))
|
||||
|
||||
_, err = x.Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeLegal,
|
||||
Content: `{
|
||||
"terms_of_service_original_text":"tos",
|
||||
"terms_of_service_parsed_text":"tos",
|
||||
"privacy_policy_original_text":"privacy",
|
||||
"privacy_policy_parsed_text":"privacy",
|
||||
"external_content_display":"always_display"
|
||||
}`,
|
||||
Status: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = x.Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeLogin,
|
||||
Content: `{
|
||||
"allow_new_registrations":true,
|
||||
"allow_email_registrations":true,
|
||||
"allow_password_login":true,
|
||||
"login_required":false,
|
||||
"allow_email_domains":[]
|
||||
}`,
|
||||
Status: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = x.Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeGeneral,
|
||||
Content: `{
|
||||
"name":"site",
|
||||
"short_description":"short",
|
||||
"description":"description",
|
||||
"site_url":"https://example.com",
|
||||
"contact_email":"admin@example.com",
|
||||
"check_update":true
|
||||
}`,
|
||||
Status: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, splitLegalMenu(context.TODO(), x))
|
||||
|
||||
login := &entity.SiteInfo{}
|
||||
exist, err := x.Where("type = ?", constant.SiteTypeLogin).Get(login)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exist)
|
||||
|
||||
var result struct {
|
||||
RequireEmailVerification bool `json:"require_email_verification"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(login.Content), &result))
|
||||
assert.True(t, result.RequireEmailVerification)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func repairAdvancedSiteInfo(ctx context.Context, x *xorm.Engine) error {
|
||||
advanced := &entity.SiteInfo{}
|
||||
exists, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeAdvanced}).Get(advanced)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
write := &entity.SiteInfo{}
|
||||
exists, err = x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeWrite}).Get(write)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
siteWrite := &schema.SiteWriteResp{}
|
||||
if err := json.Unmarshal([]byte(write.Content), siteWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := json.Marshal(&schema.SiteAdvancedResp{
|
||||
MaxImageSize: siteWrite.MaxImageSize,
|
||||
MaxAttachmentSize: siteWrite.MaxAttachmentSize,
|
||||
MaxImageMegapixel: siteWrite.MaxImageMegapixel,
|
||||
AuthorizedImageExtensions: siteWrite.AuthorizedImageExtensions,
|
||||
AuthorizedAttachmentExtensions: siteWrite.AuthorizedAttachmentExtensions,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = x.Context(ctx).Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeAdvanced,
|
||||
Content: string(content),
|
||||
Status: 1,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func TestRepairAdvancedSiteInfoAddsMissingSettings(t *testing.T) {
|
||||
x, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = x.Close()
|
||||
}()
|
||||
require.NoError(t, x.Sync(new(entity.SiteInfo)))
|
||||
|
||||
_, err = x.Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeWrite,
|
||||
Content: `{"max_image_size":5}`,
|
||||
Status: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var repairMigration Migration
|
||||
for _, m := range GetMigrations() {
|
||||
if m.Version() == "v2.0.4" {
|
||||
repairMigration = m
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, repairMigration)
|
||||
require.NoError(t, repairMigration.Migrate(context.Background(), x))
|
||||
|
||||
advanced := &entity.SiteInfo{}
|
||||
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.JSONEq(t, `{
|
||||
"max_image_size": 5,
|
||||
"max_attachment_size": 0,
|
||||
"max_image_megapixel": 0,
|
||||
"authorized_image_extensions": null,
|
||||
"authorized_attachment_extensions": null
|
||||
}`, advanced.Content)
|
||||
}
|
||||
|
||||
func TestRepairAdvancedSiteInfoPreservesExistingSettings(t *testing.T) {
|
||||
x, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = x.Close()
|
||||
}()
|
||||
require.NoError(t, x.Sync(new(entity.SiteInfo)))
|
||||
|
||||
const existingContent = `{"max_image_size":99}`
|
||||
_, err = x.Insert(
|
||||
&entity.SiteInfo{
|
||||
Type: constant.SiteTypeWrite,
|
||||
Content: `{invalid`,
|
||||
Status: 1,
|
||||
},
|
||||
&entity.SiteInfo{
|
||||
Type: constant.SiteTypeAdvanced,
|
||||
Content: existingContent,
|
||||
Status: 1,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, repairAdvancedSiteInfo(context.Background(), x))
|
||||
|
||||
advanced := &entity.SiteInfo{}
|
||||
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.JSONEq(t, existingContent, advanced.Content)
|
||||
}
|
||||
@@ -196,8 +196,12 @@ func (ar *answerRepo) GetAnswerCount(ctx context.Context) (count int64, err erro
|
||||
// GetAnswerList get answer list all
|
||||
func (ar *answerRepo) GetAnswerList(ctx context.Context, answer *entity.Answer) (answerList []*entity.Answer, err error) {
|
||||
answerList = make([]*entity.Answer, 0)
|
||||
answer.ID = uid.DeShortID(answer.ID)
|
||||
answer.QuestionID = uid.DeShortID(answer.QuestionID)
|
||||
if len(answer.ID) > 0 {
|
||||
answer.ID = uid.DeShortID(answer.ID)
|
||||
}
|
||||
if len(answer.QuestionID) > 0 {
|
||||
answer.QuestionID = uid.DeShortID(answer.QuestionID)
|
||||
}
|
||||
err = ar.data.DB.Context(ctx).Find(&answerList, answer)
|
||||
if err != nil {
|
||||
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||
|
||||
@@ -81,3 +81,11 @@ func (ar *apiKeyRepo) DeleteAPIKey(ctx context.Context, id int) (err error) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (ar *apiKeyRepo) DeleteAPIKeysByUserID(ctx context.Context, userID string) (err error) {
|
||||
_, err = ar.data.DB.Context(ctx).Where("user_id = ?", userID).Delete(&entity.APIKey{})
|
||||
if err != nil {
|
||||
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -817,10 +817,9 @@ func (qr *questionRepo) UpdateQuestionLinkStatus(ctx context.Context, status int
|
||||
}
|
||||
|
||||
// GetQuestionLink get linked question to questionID
|
||||
func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
|
||||
func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
|
||||
questionList = make([]*entity.Question, 0)
|
||||
questionID = uid.DeShortID(questionID)
|
||||
questionStatus := []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending}
|
||||
if questionID == "0" {
|
||||
return nil, 0, errors.InternalServer(reason.DatabaseError).WithError(
|
||||
fmt.Errorf("questionID is empty"),
|
||||
@@ -833,8 +832,15 @@ func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int,
|
||||
Where("question_link.to_question_id = ? AND question.show = ?", questionID, entity.QuestionShow).
|
||||
Distinct("question.id").
|
||||
Where("question_link.status = ?", entity.QuestionLinkStatusAvailable).
|
||||
Select("question.*").
|
||||
In("question.status", questionStatus)
|
||||
Select("question.*")
|
||||
switch {
|
||||
case isAdminModerator:
|
||||
session.Where("question.status IN (?, ?, ?)", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending)
|
||||
case loginUserID != "":
|
||||
session.Where("(question.status IN (?, ?) OR (question.status = ? AND question.user_id = ?))", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending, loginUserID)
|
||||
default:
|
||||
session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
|
||||
}
|
||||
|
||||
switch orderCond {
|
||||
case "newest":
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/repo/question"
|
||||
"github.com/apache/answer/internal/repo/unique"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQuestionRepoGetQuestionLinkRespectsPendingVisibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
|
||||
|
||||
newQuestion := func(userID, title string, status, show int) *entity.Question {
|
||||
q := &entity.Question{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
OriginalText: title,
|
||||
ParsedText: title,
|
||||
Status: status,
|
||||
Show: show,
|
||||
}
|
||||
require.NoError(t, questionRepo.AddQuestion(ctx, q))
|
||||
return q
|
||||
}
|
||||
|
||||
target := newQuestion("link-target-owner", "link target", entity.QuestionStatusAvailable, entity.QuestionShow)
|
||||
available := newQuestion("link-author", "available", entity.QuestionStatusAvailable, entity.QuestionShow)
|
||||
pendingOwner := newQuestion("link-author", "pending owner", entity.QuestionStatusPending, entity.QuestionShow)
|
||||
pendingOther := newQuestion("link-other", "pending other", entity.QuestionStatusPending, entity.QuestionShow)
|
||||
hidden := newQuestion("link-author", "hidden", entity.QuestionStatusAvailable, entity.QuestionHide)
|
||||
questions := []*entity.Question{target, available, pendingOwner, pendingOther, hidden}
|
||||
|
||||
for _, from := range questions[1:] {
|
||||
_, err := testDataSource.DB.Context(ctx).Insert(&entity.QuestionLink{
|
||||
FromQuestionID: from.ID,
|
||||
ToQuestionID: target.ID,
|
||||
Status: entity.QuestionLinkStatusAvailable,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = testDataSource.DB.Context(ctx).Where("to_question_id = ?", target.ID).Delete(&entity.QuestionLink{})
|
||||
for _, q := range questions {
|
||||
_, _ = testDataSource.DB.Context(ctx).ID(q.ID).Delete(&entity.Question{})
|
||||
}
|
||||
})
|
||||
|
||||
assertLinkedIDs := func(loginUserID string, isAdminModerator bool, want ...string) {
|
||||
got, _, err := questionRepo.GetQuestionLink(ctx, 1, 20, target.ID, loginUserID, isAdminModerator, "newest", 0)
|
||||
require.NoError(t, err)
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, q := range got {
|
||||
gotIDs = append(gotIDs, q.ID)
|
||||
}
|
||||
require.ElementsMatch(t, want, gotIDs)
|
||||
}
|
||||
|
||||
assertLinkedIDs("", false, available.ID)
|
||||
assertLinkedIDs("link-author", false, available.ID, pendingOwner.ID)
|
||||
assertLinkedIDs("link-other", false, available.ID, pendingOther.ID)
|
||||
assertLinkedIDs("link-moderator", true, available.ID, pendingOwner.ID, pendingOther.ID)
|
||||
}
|
||||
@@ -195,6 +195,7 @@ func (tr *tagRelRepo) CountTagRelByTagID(ctx context.Context, tagID string) (cou
|
||||
// GetTagRelDefaultStatusByObjectID get tag rel default status
|
||||
func (tr *tagRelRepo) GetTagRelDefaultStatusByObjectID(ctx context.Context, objectID string) (status int, err error) {
|
||||
question := entity.Question{}
|
||||
objectID = uid.DeShortID(objectID)
|
||||
exist, err := tr.data.DB.Context(ctx).ID(objectID).Cols("show", "status").Get(&question)
|
||||
if err != nil {
|
||||
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
|
||||
|
||||
@@ -21,7 +21,6 @@ package tag_common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -171,10 +170,20 @@ func (tr *tagCommonRepo) GetTagPage(ctx context.Context, page, pageSize int, tag
|
||||
session := tr.data.DB.Context(ctx)
|
||||
|
||||
if len(tag.SlugName) > 0 {
|
||||
// Both sides lowered, so the search is case-insensitive.
|
||||
//
|
||||
// This previously read LOWER(%s) formatted against the *search term*,
|
||||
// which put the function name into the value: the query became
|
||||
// slug_name LIKE '%LOWER(coco)%' and could never match. Only the
|
||||
// display_name clause did anything, and that is case-sensitive on
|
||||
// Postgres, so typing a tag in lower case -- which is how tags are
|
||||
// written and therefore how anyone types them -- returned nothing at all
|
||||
// and read as "no such tag".
|
||||
search := searchTermForTag(tag.SlugName)
|
||||
mainTagCond := builder.And(
|
||||
builder.Or(
|
||||
builder.Like{"slug_name", fmt.Sprintf("LOWER(%s)", tag.SlugName)},
|
||||
builder.Like{"display_name", tag.SlugName},
|
||||
builder.Like{"LOWER(slug_name)", search},
|
||||
builder.Like{"LOWER(display_name)", search},
|
||||
),
|
||||
builder.Eq{"main_tag_id": 0},
|
||||
)
|
||||
@@ -293,3 +302,10 @@ func (tr *tagCommonRepo) UpdateTagsAttribute(ctx context.Context, tags []string,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// searchTermForTag normalises a tag search term. Lowering it here, and lowering
|
||||
// the columns in the query, is what makes the search case-insensitive: tags are
|
||||
// written in lower case, so that is how people type them.
|
||||
func searchTermForTag(term string) string {
|
||||
return strings.ToLower(strings.TrimSpace(term))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package tag_common
|
||||
|
||||
import "testing"
|
||||
|
||||
// The bug: the search term was formatted into LOWER(%s), which put the function
|
||||
// name into the value rather than applying it to the column, so the query became
|
||||
// slug_name LIKE '%LOWER(coco)%' and matched nothing. Only display_name did any
|
||||
// work, and that is case-sensitive on Postgres -- so typing a tag the way tags
|
||||
// are actually written returned "no such tag".
|
||||
func TestSearchTermIsLoweredNotWrapped(t *testing.T) {
|
||||
for _, in := range []string{"Coco", "COCO", "coco"} {
|
||||
got := searchTermForTag(in)
|
||||
if got != "coco" {
|
||||
t.Errorf("searchTermForTag(%q) = %q, want %q", in, got, "coco")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ package user_external_login
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/base/data"
|
||||
@@ -136,3 +137,28 @@ func (ur *userExternalLoginRepo) GetCacheUserExternalLoginInfo(
|
||||
_ = json.Unmarshal([]byte(res), &info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (ur *userExternalLoginRepo) SetCacheOAuthState(
|
||||
ctx context.Context, state string, info *schema.ExternalLoginOAuthState, duration time.Duration) (err error) {
|
||||
cacheData, _ := json.Marshal(info)
|
||||
return ur.data.Cache.SetString(ctx, constant.ConnectorOAuthStateCacheKey+state,
|
||||
string(cacheData), duration)
|
||||
}
|
||||
|
||||
func (ur *userExternalLoginRepo) GetCacheOAuthState(
|
||||
ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error) {
|
||||
res, exist, err := ur.data.Cache.GetString(ctx, constant.ConnectorOAuthStateCacheKey+state)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
if !exist {
|
||||
return nil, nil
|
||||
}
|
||||
info = &schema.ExternalLoginOAuthState{}
|
||||
_ = json.Unmarshal([]byte(res), &info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (ur *userExternalLoginRepo) DeleteCacheOAuthState(ctx context.Context, state string) (err error) {
|
||||
return ur.data.Cache.Del(ctx, constant.ConnectorOAuthStateCacheKey+state)
|
||||
}
|
||||
|
||||
@@ -53,10 +53,11 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
|
||||
filePath := c.Param("filepath")
|
||||
// The original filename is 123.pdf
|
||||
originalFilename := filepath.Base(filePath)
|
||||
// The real filename is hash.pdf
|
||||
realFilename := strings.TrimSuffix(filePath, "/"+originalFilename) + filepath.Ext(originalFilename)
|
||||
// The file local path is /uploads/files/post/hash.pdf
|
||||
fileLocalPath := filepath.Join(a.serviceConfig.UploadPath, constant.FilesPostSubPath, realFilename)
|
||||
fileLocalPath, ok := attachmentFileLocalPath(a.serviceConfig.UploadPath, filePath, originalFilename)
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, "/404")
|
||||
return
|
||||
}
|
||||
// If the file is not exist, return 404
|
||||
if !dir.CheckFileExist(fileLocalPath) {
|
||||
c.Redirect(http.StatusFound, "/404")
|
||||
@@ -65,3 +66,14 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
|
||||
c.FileAttachment(fileLocalPath, originalFilename)
|
||||
})
|
||||
}
|
||||
|
||||
func attachmentFileLocalPath(uploadPath, requestPath, originalFilename string) (string, bool) {
|
||||
realFilename := strings.TrimSuffix(requestPath, "/"+originalFilename) + filepath.Ext(originalFilename)
|
||||
attachmentRoot := filepath.Join(uploadPath, constant.FilesPostSubPath)
|
||||
fileLocalPath := filepath.Join(attachmentRoot, realFilename)
|
||||
relPath, err := filepath.Rel(attachmentRoot, fileLocalPath)
|
||||
if err != nil || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return fileLocalPath, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package router
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
)
|
||||
|
||||
func TestAttachmentFileLocalPathRejectsTraversal(t *testing.T) {
|
||||
uploadPath := t.TempDir()
|
||||
|
||||
filePath, ok := attachmentFileLocalPath(uploadPath, "/hash/report.pdf", "report.pdf")
|
||||
if !ok {
|
||||
t.Fatal("valid attachment path was rejected")
|
||||
}
|
||||
want := filepath.Join(uploadPath, constant.FilesPostSubPath, "hash.pdf")
|
||||
if filePath != want {
|
||||
t.Fatalf("attachment path = %q, want %q", filePath, want)
|
||||
}
|
||||
|
||||
for _, requestPath := range []string{"/../../outside/secret.txt", "/hash/../../../secret.txt"} {
|
||||
if _, ok := attachmentFileLocalPath(uploadPath, requestPath, filepath.Base(requestPath)); ok {
|
||||
t.Fatalf("traversal path %q was accepted", requestPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type AIConversationRecord struct {
|
||||
ChatCompletionID string `json:"chat_completion_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Helpful int `json:"helpful"`
|
||||
Unhelpful int `json:"unhelpful"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
|
||||
@@ -21,6 +21,7 @@ package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
)
|
||||
@@ -28,7 +29,7 @@ import (
|
||||
const (
|
||||
AccountActivationSourceType EmailSourceType = "account-activation"
|
||||
PasswordResetSourceType EmailSourceType = "password-reset"
|
||||
ConfirmNewEmailSourceType EmailSourceType = "password-reset"
|
||||
ConfirmNewEmailSourceType EmailSourceType = "confirm-new-email"
|
||||
UnsubscribeSourceType EmailSourceType = "unsubscribe"
|
||||
BindingSourceType EmailSourceType = "binding"
|
||||
)
|
||||
@@ -56,6 +57,10 @@ func (r *EmailCodeContent) FromJSONString(data string) error {
|
||||
return json.Unmarshal([]byte(data), &r)
|
||||
}
|
||||
|
||||
func (r *EmailCodeContent) IsSourceType(sourceTypes ...EmailSourceType) bool {
|
||||
return slices.Contains(sourceTypes, r.SourceType)
|
||||
}
|
||||
|
||||
type RegisterTemplateData struct {
|
||||
SiteName string
|
||||
RegisterUrl string
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package schema
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEmailCodeContentIsSourceType(t *testing.T) {
|
||||
if PasswordResetSourceType == ConfirmNewEmailSourceType {
|
||||
t.Fatal("password reset and confirm new email source types must be distinct")
|
||||
}
|
||||
|
||||
passwordResetCode := &EmailCodeContent{SourceType: PasswordResetSourceType}
|
||||
if !passwordResetCode.IsSourceType(PasswordResetSourceType) {
|
||||
t.Fatal("password reset code should match the password reset source type")
|
||||
}
|
||||
if passwordResetCode.IsSourceType(UnsubscribeSourceType, ConfirmNewEmailSourceType) {
|
||||
t.Fatal("password reset code must not match another source type")
|
||||
}
|
||||
|
||||
unsubscribeCode := &EmailCodeContent{SourceType: UnsubscribeSourceType}
|
||||
if unsubscribeCode.IsSourceType(PasswordResetSourceType, AccountActivationSourceType) {
|
||||
t.Fatal("unsubscribe code must not match an account credential source type")
|
||||
}
|
||||
}
|
||||
@@ -514,13 +514,13 @@ type PersonalCollectionPageReq struct {
|
||||
}
|
||||
|
||||
type GetQuestionLinkReq struct {
|
||||
Page int `validate:"omitempty,min=1" form:"page"`
|
||||
PageSize int `validate:"omitempty,min=1,max=100" form:"page_size"`
|
||||
QuestionID string `validate:"required" form:"question_id"`
|
||||
OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
|
||||
InDays int `validate:"omitempty,min=1" form:"in_days"`
|
||||
|
||||
LoginUserID string `json:"-"`
|
||||
Page int `validate:"omitempty,min=1" form:"page"`
|
||||
PageSize int `validate:"omitempty,min=1,max=100" form:"page_size"`
|
||||
QuestionID string `validate:"required" form:"question_id"`
|
||||
OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
|
||||
InDays int `validate:"omitempty,min=1" form:"in_days"`
|
||||
LoginUserID string `json:"-"`
|
||||
IsAdminModerator bool `json:"-"`
|
||||
}
|
||||
|
||||
type GetQuestionLinkResp struct {
|
||||
|
||||
@@ -218,10 +218,20 @@ type SiteUsersReq struct {
|
||||
|
||||
// SiteLoginReq site login request
|
||||
type SiteLoginReq struct {
|
||||
AllowNewRegistrations bool `json:"allow_new_registrations"`
|
||||
AllowEmailRegistrations bool `json:"allow_email_registrations"`
|
||||
AllowPasswordLogin bool `json:"allow_password_login"`
|
||||
AllowEmailDomains []string `json:"allow_email_domains"`
|
||||
AllowNewRegistrations bool `json:"allow_new_registrations"`
|
||||
AllowEmailRegistrations bool `json:"allow_email_registrations"`
|
||||
AllowPasswordLogin bool `json:"allow_password_login"`
|
||||
AllowEmailDomains []string `json:"allow_email_domains"`
|
||||
RequireEmailVerification *bool `validate:"required" json:"require_email_verification" swaggertype:"boolean"`
|
||||
}
|
||||
|
||||
// SiteLoginResp site login response
|
||||
type SiteLoginResp struct {
|
||||
AllowNewRegistrations bool `json:"allow_new_registrations"`
|
||||
AllowEmailRegistrations bool `json:"allow_email_registrations"`
|
||||
AllowPasswordLogin bool `json:"allow_password_login"`
|
||||
AllowEmailDomains []string `json:"allow_email_domains"`
|
||||
RequireEmailVerification bool `json:"require_email_verification"`
|
||||
}
|
||||
|
||||
// SiteCustomCssHTMLReq site custom css html
|
||||
@@ -310,9 +320,6 @@ type SiteInterfaceResp SiteInterfaceReq
|
||||
// SiteBrandingResp site branding response
|
||||
type SiteBrandingResp SiteBrandingReq
|
||||
|
||||
// SiteLoginResp site login response
|
||||
type SiteLoginResp SiteLoginReq
|
||||
|
||||
// SiteCustomCssHTMLResp site custom css html response
|
||||
type SiteCustomCssHTMLResp SiteCustomCssHTMLReq
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/base/validator"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSiteLoginReqRequireEmailVerificationValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "omitted is invalid",
|
||||
payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "null is invalid",
|
||||
payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":null}`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "false is valid",
|
||||
payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":false}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "true is valid",
|
||||
payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":true}`,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := &SiteLoginReq{}
|
||||
require.NoError(t, json.Unmarshal([]byte(tt.payload), req))
|
||||
|
||||
_, err := validator.GetValidatorByLang(i18n.DefaultLanguage).Check(req)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@
|
||||
|
||||
package schema
|
||||
|
||||
const (
|
||||
ExternalLoginOAuthStateLoginIntent = "login"
|
||||
ExternalLoginOAuthStateBindIntent = "bind"
|
||||
)
|
||||
|
||||
// UserExternalLoginResp user external login resp
|
||||
type UserExternalLoginResp struct {
|
||||
BindingKey string `json:"binding_key"`
|
||||
@@ -75,6 +80,13 @@ type ExternalLoginUserInfoCache struct {
|
||||
Bio string
|
||||
}
|
||||
|
||||
// ExternalLoginOAuthState stores the local OAuth request state.
|
||||
type ExternalLoginOAuthState struct {
|
||||
Provider string `json:"provider"`
|
||||
Intent string `json:"intent"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalLoginUnbindingReq external login unbinding user
|
||||
type ExternalLoginUnbindingReq struct {
|
||||
ExternalID string `validate:"required,gt=0,lte=128" json:"external_id"`
|
||||
|
||||
@@ -219,12 +219,13 @@ type UserEmailLoginReq struct {
|
||||
|
||||
// UserRegisterReq user register request
|
||||
type UserRegisterReq struct {
|
||||
Name string `validate:"required,gte=2,lte=30" json:"name"`
|
||||
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
|
||||
Pass string `validate:"required,gte=8,lte=32" json:"pass"`
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
CaptchaCode string `json:"captcha_code"`
|
||||
IP string `json:"-" `
|
||||
Name string `validate:"required,gte=2,lte=30" json:"name"`
|
||||
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
|
||||
Pass string `validate:"required,gte=8,lte=32" json:"pass"`
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
CaptchaCode string `json:"captcha_code"`
|
||||
IP string `json:"-" `
|
||||
RequireEmailVerification bool `json:"-"`
|
||||
}
|
||||
|
||||
func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err error) {
|
||||
|
||||
@@ -51,6 +51,7 @@ type ConversationMessage struct {
|
||||
ChatCompletionID string `json:"chat_completion_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
}
|
||||
|
||||
// aiConversationService
|
||||
@@ -97,6 +98,7 @@ func (s *aiConversationService) SaveConversationRecords(ctx context.Context, con
|
||||
}
|
||||
|
||||
content := strings.Builder{}
|
||||
reasoning := strings.Builder{}
|
||||
|
||||
for _, record := range records {
|
||||
if len(record.ChatCompletionID) > 0 {
|
||||
@@ -120,12 +122,17 @@ func (s *aiConversationService) SaveConversationRecords(ctx context.Context, con
|
||||
|
||||
content.WriteString(record.Content)
|
||||
content.WriteString("\n")
|
||||
if record.ReasoningContent != "" {
|
||||
reasoning.WriteString(record.ReasoningContent)
|
||||
reasoning.WriteString("\n")
|
||||
}
|
||||
}
|
||||
aiRecord := &entity.AIConversationRecord{
|
||||
ConversationID: conversationID,
|
||||
ChatCompletionID: chatcmplID,
|
||||
Role: "assistant",
|
||||
Content: content.String(),
|
||||
ReasoningContent: reasoning.String(),
|
||||
Helpful: 0,
|
||||
Unhelpful: 0,
|
||||
}
|
||||
@@ -190,6 +197,7 @@ func (s *aiConversationService) GetConversationDetail(ctx context.Context, req *
|
||||
ChatCompletionID: record.ChatCompletionID,
|
||||
Role: record.Role,
|
||||
Content: record.Content,
|
||||
ReasoningContent: record.ReasoningContent,
|
||||
Helpful: record.Helpful,
|
||||
Unhelpful: record.Unhelpful,
|
||||
CreatedAt: record.CreatedAt.Unix(),
|
||||
@@ -319,6 +327,7 @@ func (s *aiConversationService) GetConversationDetailForAdmin(ctx context.Contex
|
||||
ChatCompletionID: record.ChatCompletionID,
|
||||
Role: record.Role,
|
||||
Content: record.Content,
|
||||
ReasoningContent: record.ReasoningContent,
|
||||
Helpful: record.Helpful,
|
||||
Unhelpful: record.Unhelpful,
|
||||
CreatedAt: record.CreatedAt.Unix(),
|
||||
|
||||
@@ -35,6 +35,7 @@ type APIKeyRepo interface {
|
||||
UpdateAPIKey(ctx context.Context, apiKey entity.APIKey) (err error)
|
||||
AddAPIKey(ctx context.Context, apiKey entity.APIKey) (err error)
|
||||
DeleteAPIKey(ctx context.Context, id int) (err error)
|
||||
DeleteAPIKeysByUserID(ctx context.Context, userID string) (err error)
|
||||
}
|
||||
|
||||
type APIKeyService struct {
|
||||
@@ -114,3 +115,7 @@ func (s *APIKeyService) DeleteAPIKey(ctx context.Context, req *schema.DeleteAPIK
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) DeleteUserAPIKeys(ctx context.Context, userID string) error {
|
||||
return s.apiKeyRepo.DeleteAPIKeysByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/apache/answer/internal/service/apikey"
|
||||
"github.com/apache/answer/pkg/token"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
// AuthRepo auth repository
|
||||
@@ -198,9 +197,7 @@ func (as *AuthService) AuthAPIKey(ctx context.Context, read bool, apiKey string)
|
||||
}
|
||||
// If the request is not read-only, check if the API key has write permissions
|
||||
if !read && apiKeyInfo.Scope == "read-only" {
|
||||
log.Warnf("API key %s does not have write permissions", apiKeyInfo.AccessKey)
|
||||
return false, nil
|
||||
}
|
||||
log.Infof("API key %s is valid, scope: %s", apiKeyInfo.AccessKey, apiKeyInfo.Scope)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
func TestAuthAPIKeyDoesNotLogAccessKey(t *testing.T) {
|
||||
const accessKey = "sk_sensitive-api-key-must-not-be-logged"
|
||||
|
||||
logger := &authTestLogger{}
|
||||
previousLogger := log.GetLogger()
|
||||
log.SetLogger(logger)
|
||||
t.Cleanup(func() { log.SetLogger(previousLogger) })
|
||||
|
||||
service := NewAuthService(nil, &authTestAPIKeyRepo{
|
||||
key: &entity.APIKey{AccessKey: accessKey, Scope: "read-only"},
|
||||
})
|
||||
|
||||
pass, err := service.AuthAPIKey(context.Background(), true, accessKey)
|
||||
if err != nil || !pass {
|
||||
t.Fatalf("read-only API key should authenticate read request: pass=%v err=%v", pass, err)
|
||||
}
|
||||
|
||||
pass, err = service.AuthAPIKey(context.Background(), false, accessKey)
|
||||
if err != nil || pass {
|
||||
t.Fatalf("read-only API key should not authenticate write request: pass=%v err=%v", pass, err)
|
||||
}
|
||||
|
||||
if logs := logger.String(); strings.Contains(logs, accessKey) {
|
||||
t.Fatalf("authentication logs contain API key: %s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
type authTestAPIKeyRepo struct {
|
||||
key *entity.APIKey
|
||||
}
|
||||
|
||||
func (r *authTestAPIKeyRepo) GetAPIKeyList(context.Context) ([]*entity.APIKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *authTestAPIKeyRepo) GetAPIKey(context.Context, string) (*entity.APIKey, bool, error) {
|
||||
return r.key, true, nil
|
||||
}
|
||||
|
||||
func (r *authTestAPIKeyRepo) UpdateAPIKey(context.Context, entity.APIKey) error { return nil }
|
||||
|
||||
func (r *authTestAPIKeyRepo) AddAPIKey(context.Context, entity.APIKey) error { return nil }
|
||||
|
||||
func (r *authTestAPIKeyRepo) DeleteAPIKey(context.Context, int) error { return nil }
|
||||
|
||||
func (r *authTestAPIKeyRepo) DeleteAPIKeysByUserID(context.Context, string) error { return nil }
|
||||
|
||||
type authTestLogger struct {
|
||||
entries []string
|
||||
}
|
||||
|
||||
func (l *authTestLogger) Debug(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Debugf(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) Info(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Infof(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) Warn(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Warnf(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) Error(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Errorf(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) String() string { return strings.Join(l.entries, "\n") }
|
||||
@@ -471,7 +471,7 @@ func (as *AnswerService) AcceptAnswer(ctx context.Context, req *schema.AcceptAns
|
||||
}
|
||||
|
||||
// check answer belong to question
|
||||
if acceptedAnswerInfo.QuestionID != req.QuestionID {
|
||||
if !sameObjectID(acceptedAnswerInfo.QuestionID, req.QuestionID) {
|
||||
return errors.BadRequest(reason.AnswerNotFound)
|
||||
}
|
||||
acceptedAnswerInfo.ID = uid.DeShortID(acceptedAnswerInfo.ID)
|
||||
@@ -513,6 +513,12 @@ func (as *AnswerService) AcceptAnswer(ctx context.Context, req *schema.AcceptAns
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameObjectID reports whether two object ids refer to the same row,
|
||||
// regardless of whether each is in short-id or long-id form.
|
||||
func sameObjectID(a, b string) bool {
|
||||
return uid.DeShortID(a) == uid.DeShortID(b)
|
||||
}
|
||||
|
||||
func (as *AnswerService) updateAnswerRank(ctx context.Context, userID string,
|
||||
questionInfo *entity.Question, newAnswerInfo *entity.Answer, oldAnswerInfo *entity.Answer,
|
||||
) {
|
||||
@@ -548,12 +554,18 @@ func (as *AnswerService) Get(ctx context.Context, answerID, loginUserID string,
|
||||
if !exist {
|
||||
return nil, nil, false, errors.NotFound(reason.AnswerNotFound)
|
||||
}
|
||||
|
||||
if (question.Status == entity.QuestionStatusDeleted ||
|
||||
question.Status == entity.QuestionStatusPending ||
|
||||
question.Show == entity.QuestionHide) &&
|
||||
!isAdminModerator && question.UserID != loginUserID {
|
||||
return nil, nil, false, errors.NotFound(reason.AnswerNotFound)
|
||||
}
|
||||
if (answerInfo.Status == entity.AnswerStatusDeleted ||
|
||||
answerInfo.Status == entity.AnswerStatusPending) &&
|
||||
!isAdminModerator && answerInfo.UserID != loginUserID {
|
||||
return nil, nil, false, errors.NotFound(reason.AnswerNotFound)
|
||||
}
|
||||
info := as.ShowFormat(ctx, answerInfo)
|
||||
// todo questionFunc
|
||||
questionInfo, err := as.questionCommon.Info(ctx, answerInfo.QuestionID, loginUserID)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package content
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/pkg/uid"
|
||||
)
|
||||
|
||||
// TestSameObjectID guards the AcceptAnswer ownership check (issue #1541).
|
||||
//
|
||||
// When short links are enabled, answerRepo.GetByID re-encodes the answer's
|
||||
// QuestionID to its short form while the controller de-shorts req.QuestionID
|
||||
// to its long form. The two encodings of the same question must be treated as
|
||||
// equal, or accepting any answer fails with "Answer do not found".
|
||||
func TestSameObjectID(t *testing.T) {
|
||||
const longQID = "10010000000000001"
|
||||
shortQID := uid.EnShortID(longQID) // e.g. "D1D1"
|
||||
if shortQID == "" || shortQID == longQID {
|
||||
t.Fatalf("precondition failed: EnShortID(%q)=%q, want a distinct short id", longQID, shortQID)
|
||||
}
|
||||
otherLongQID := "10010000000000002"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "short answer-side id vs long request-side id, same question (the bug)",
|
||||
a: shortQID,
|
||||
b: longQID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "both long, same question (default permalink)",
|
||||
a: longQID,
|
||||
b: longQID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "both short, same question",
|
||||
a: shortQID,
|
||||
b: shortQID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different questions must stay rejected (privilege-escalation guard)",
|
||||
a: shortQID,
|
||||
b: otherLongQID,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sameObjectID(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("sameObjectID(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -64,7 +65,6 @@ import (
|
||||
"github.com/jinzhu/copier"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// QuestionRepo question repository
|
||||
@@ -1091,13 +1091,8 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// If the question is deleted or pending, only the administrator and the author can view it
|
||||
if (question.Status == entity.QuestionStatusDeleted ||
|
||||
question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
|
||||
return nil, errors.NotFound(reason.QuestionNotFound)
|
||||
}
|
||||
if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
|
||||
return nil, errors.NotFound(reason.QuestionNotFound)
|
||||
if err = checkQuestionVisibility(question, userID, per); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if question.Status != entity.QuestionStatusClosed {
|
||||
per.CanReopen = false
|
||||
@@ -1142,6 +1137,19 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
|
||||
return question, nil
|
||||
}
|
||||
|
||||
func checkQuestionVisibility(question *schema.QuestionInfoResp, userID string, per schema.QuestionPermission) error {
|
||||
// Deleted and pending questions are visible only to their author or users who can reopen them.
|
||||
if (question.Status == entity.QuestionStatusDeleted ||
|
||||
question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
|
||||
return errors.NotFound(reason.QuestionNotFound)
|
||||
}
|
||||
// Hidden questions are visible only to their author or an administrator/moderator.
|
||||
if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
|
||||
return errors.NotFound(reason.QuestionNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQuestionAndAddPV get question one
|
||||
func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID, loginUserID string,
|
||||
per schema.QuestionPermission) (
|
||||
@@ -1153,7 +1161,11 @@ func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID,
|
||||
return qs.GetQuestion(ctx, questionID, loginUserID, per)
|
||||
}
|
||||
|
||||
func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID string) (inviteList []*schema.UserBasicInfo, err error) {
|
||||
func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID, userID string,
|
||||
per schema.QuestionPermission) (inviteList []*schema.UserBasicInfo, err error) {
|
||||
if _, err = qs.GetQuestion(ctx, questionID, userID, per); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qs.questioncommon.InviteUserInfo(ctx, questionID)
|
||||
}
|
||||
|
||||
@@ -1748,7 +1760,7 @@ func (qs *QuestionService) GetQuestionLink(ctx context.Context, req *schema.GetQ
|
||||
req.InDays = schema.HotInDays
|
||||
}
|
||||
|
||||
questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.OrderCond, req.InDays)
|
||||
questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.LoginUserID, req.IsAdminModerator, req.OrderCond, req.InDays)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package content
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
func TestCheckQuestionVisibility(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
status int
|
||||
show int
|
||||
userID string
|
||||
viewer string
|
||||
per schema.QuestionPermission
|
||||
allow bool
|
||||
}{
|
||||
{"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "", schema.QuestionPermission{}, true},
|
||||
{"pending question anonymous", entity.QuestionStatusPending, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
|
||||
{"pending question author", entity.QuestionStatusPending, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, true},
|
||||
{"pending question reviewer", entity.QuestionStatusPending, entity.QuestionShow, "author", "reviewer", schema.QuestionPermission{CanReopen: true}, true},
|
||||
{"deleted question anonymous", entity.QuestionStatusDeleted, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
|
||||
{"hidden question anonymous", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "", schema.QuestionPermission{}, false},
|
||||
{"hidden question author", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", schema.QuestionPermission{}, true},
|
||||
{"hidden question moderator", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "moderator", schema.QuestionPermission{IsAdminModerator: true}, true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
question := &schema.QuestionInfoResp{
|
||||
Status: testCase.status,
|
||||
Show: testCase.show,
|
||||
UserID: testCase.userID,
|
||||
}
|
||||
err := checkQuestionVisibility(question, testCase.viewer, testCase.per)
|
||||
if testCase.allow && err != nil {
|
||||
t.Fatalf("visibility unexpectedly denied: %v", err)
|
||||
}
|
||||
if !testCase.allow && err == nil {
|
||||
t.Fatal("visibility unexpectedly allowed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,14 +114,20 @@ func (rs *RevisionService) RevisionAudit(ctx context.Context, req *schema.Revisi
|
||||
if revisioninfo.Status != entity.RevisionUnreviewedStatus {
|
||||
return
|
||||
}
|
||||
objectType, objectTypeerr := obj.GetObjectTypeStrByObjectID(revisioninfo.ObjectID)
|
||||
if objectTypeerr != nil {
|
||||
return objectTypeerr
|
||||
}
|
||||
if req.Operation == schema.RevisionAuditReject {
|
||||
if err = checkRevisionAuditPermission(req, objectType); err != nil {
|
||||
return err
|
||||
}
|
||||
err = rs.revisionRepo.UpdateStatus(ctx, req.ID, entity.RevisionReviewRejectStatus, req.UserID)
|
||||
return
|
||||
}
|
||||
if req.Operation == schema.RevisionAuditApprove {
|
||||
objectType, objectTypeerr := obj.GetObjectTypeStrByObjectID(revisioninfo.ObjectID)
|
||||
if objectTypeerr != nil {
|
||||
return objectTypeerr
|
||||
if err = checkRevisionAuditPermission(req, objectType); err != nil {
|
||||
return err
|
||||
}
|
||||
revisionitem := &schema.GetRevisionResp{}
|
||||
_ = copier.Copy(revisionitem, revisioninfo)
|
||||
@@ -129,23 +135,11 @@ func (rs *RevisionService) RevisionAudit(ctx context.Context, req *schema.Revisi
|
||||
var saveErr error
|
||||
switch objectType {
|
||||
case constant.QuestionObjectType:
|
||||
if !req.CanReviewQuestion {
|
||||
saveErr = errors.BadRequest(reason.RevisionNoPermission)
|
||||
} else {
|
||||
saveErr = rs.revisionAuditQuestion(ctx, revisionitem)
|
||||
}
|
||||
saveErr = rs.revisionAuditQuestion(ctx, revisionitem)
|
||||
case constant.AnswerObjectType:
|
||||
if !req.CanReviewAnswer {
|
||||
saveErr = errors.BadRequest(reason.RevisionNoPermission)
|
||||
} else {
|
||||
saveErr = rs.revisionAuditAnswer(ctx, revisionitem)
|
||||
}
|
||||
saveErr = rs.revisionAuditAnswer(ctx, revisionitem)
|
||||
case constant.TagObjectType:
|
||||
if !req.CanReviewTag {
|
||||
saveErr = errors.BadRequest(reason.RevisionNoPermission)
|
||||
} else {
|
||||
saveErr = rs.revisionAuditTag(ctx, revisionitem)
|
||||
}
|
||||
saveErr = rs.revisionAuditTag(ctx, revisionitem)
|
||||
}
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
@@ -179,6 +173,24 @@ func (rs *RevisionService) RevisionAudit(ctx context.Context, req *schema.Revisi
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRevisionAuditPermission(req *schema.RevisionAuditReq, objectType string) error {
|
||||
switch objectType {
|
||||
case constant.QuestionObjectType:
|
||||
if !req.CanReviewQuestion {
|
||||
return errors.BadRequest(reason.RevisionNoPermission)
|
||||
}
|
||||
case constant.AnswerObjectType:
|
||||
if !req.CanReviewAnswer {
|
||||
return errors.BadRequest(reason.RevisionNoPermission)
|
||||
}
|
||||
case constant.TagObjectType:
|
||||
if !req.CanReviewTag {
|
||||
return errors.BadRequest(reason.RevisionNoPermission)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RevisionService) revisionAuditQuestion(ctx context.Context, revisionitem *schema.GetRevisionResp) (err error) {
|
||||
questioninfo, ok := revisionitem.ContentParsed.(*schema.QuestionInfoResp)
|
||||
if ok {
|
||||
|
||||
@@ -227,8 +227,9 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
|
||||
|
||||
// send email
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: req.Email,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.PasswordResetSourceType,
|
||||
Email: req.Email,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code)
|
||||
@@ -247,6 +248,9 @@ func (us *UserService) UpdatePasswordWhenForgot(ctx context.Context, req *schema
|
||||
if err != nil {
|
||||
return errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
if !data.IsSourceType(schema.PasswordResetSourceType) {
|
||||
return errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
userInfo, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
|
||||
if err != nil {
|
||||
@@ -360,7 +364,7 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
|
||||
|
||||
cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req)
|
||||
|
||||
us.cleanUpRemovedAvatar(ctx, oldUserInfo.Avatar, cond.Avatar)
|
||||
us.cleanUpRemovedAvatar(ctx, req.UserID, oldUserInfo.Avatar, cond.Avatar)
|
||||
|
||||
err = us.userRepo.UpdateInfo(ctx, cond)
|
||||
if err != nil {
|
||||
@@ -407,6 +411,7 @@ func (us *UserService) validateAvatarInfo(
|
||||
|
||||
func (us *UserService) cleanUpRemovedAvatar(
|
||||
ctx context.Context,
|
||||
updatingUserID string,
|
||||
oldAvatarJSON string,
|
||||
newAvatarJSON string,
|
||||
) {
|
||||
@@ -434,6 +439,13 @@ func (us *UserService) cleanUpRemovedAvatar(
|
||||
log.Warn("no file record found for old avatar url:", oldAvatar.Custom)
|
||||
return
|
||||
}
|
||||
if fileRecord.UserID != updatingUserID || fileRecord.Source != string(plugin.UserAvatar) {
|
||||
log.Warnf(
|
||||
"refuse to clean avatar url %q: file record owner/source mismatch (owner=%s source=%s updating_user=%s)",
|
||||
oldAvatar.Custom, fileRecord.UserID, fileRecord.Source, updatingUserID,
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := us.fileRecordService.DeleteAndMoveFileRecord(ctx, fileRecord); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
@@ -518,18 +530,20 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
|
||||
log.Errorf("set default user notification config failed, err: %v", err)
|
||||
}
|
||||
|
||||
// send email
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: registerUserInfo.Email,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
|
||||
title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL)
|
||||
err = applyRegistrationVerification(userInfo, registerUserInfo.RequireEmailVerification, registrationVerificationActions{
|
||||
sendActivationEmail: func() error {
|
||||
return us.sendRegistrationActivationEmail(ctx, userInfo)
|
||||
},
|
||||
activateUser: func() error {
|
||||
return us.userActivity.UserActive(ctx, userInfo.ID)
|
||||
},
|
||||
markEmailAvailable: func() error {
|
||||
return us.userRepo.UpdateEmailStatus(ctx, userInfo.ID, entity.EmailStatusAvailable)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
go us.emailService.SendAndSaveCode(ctx, userInfo.ID, userInfo.EMail, title, body, code, data.ToJSONString())
|
||||
|
||||
roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID)
|
||||
if err != nil {
|
||||
@@ -560,6 +574,48 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
|
||||
return resp, nil, nil
|
||||
}
|
||||
|
||||
type registrationVerificationActions struct {
|
||||
sendActivationEmail func() error
|
||||
activateUser func() error
|
||||
markEmailAvailable func() error
|
||||
}
|
||||
|
||||
func applyRegistrationVerification(
|
||||
userInfo *entity.User, requireEmailVerification bool, actions registrationVerificationActions,
|
||||
) error {
|
||||
userInfo.MailStatus = entity.EmailStatusToBeVerified
|
||||
if requireEmailVerification {
|
||||
return actions.sendActivationEmail()
|
||||
}
|
||||
|
||||
if err := actions.activateUser(); err != nil {
|
||||
log.Errorf("activate user during registration failed, fallback to email verification, err: %v", err)
|
||||
return actions.sendActivationEmail()
|
||||
}
|
||||
if err := actions.markEmailAvailable(); err != nil {
|
||||
log.Errorf("mark email available during registration failed, fallback to email verification, err: %v", err)
|
||||
return actions.sendActivationEmail()
|
||||
}
|
||||
userInfo.MailStatus = entity.EmailStatusAvailable
|
||||
return nil
|
||||
}
|
||||
|
||||
func (us *UserService) sendRegistrationActivationEmail(ctx context.Context, userInfo *entity.User) error {
|
||||
data := &schema.EmailCodeContent{
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
|
||||
title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go us.emailService.SendAndSaveCode(ctx, userInfo.ID, userInfo.EMail, title, body, code, data.ToJSONString())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) error {
|
||||
userInfo, has, err := us.userRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -570,8 +626,9 @@ func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) e
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
|
||||
@@ -589,6 +646,9 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri
|
||||
if err != nil {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
if !data.IsSourceType(schema.AccountActivationSourceType, schema.BindingSourceType) {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
userInfo, has, err := us.userRepo.GetByEmail(ctx, data.Email)
|
||||
if err != nil {
|
||||
@@ -685,8 +745,9 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: req.Email,
|
||||
UserID: req.UserID,
|
||||
SourceType: schema.ConfirmNewEmailSourceType,
|
||||
Email: req.Email,
|
||||
UserID: req.UserID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
var title, body string
|
||||
@@ -712,6 +773,9 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string
|
||||
if err != nil {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
if !data.IsSourceType(schema.ConfirmNewEmailSourceType) {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
_, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
|
||||
if err != nil {
|
||||
@@ -845,7 +909,7 @@ func (us *UserService) UserUnsubscribeNotification(
|
||||
ctx context.Context, req *schema.UserUnsubscribeNotificationReq) (err error) {
|
||||
data := &schema.EmailCodeContent{}
|
||||
err = data.FromJSONString(req.Content)
|
||||
if err != nil || len(data.UserID) == 0 {
|
||||
if err != nil || len(data.UserID) == 0 || !data.IsSourceType(schema.UnsubscribeSourceType) {
|
||||
return errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEmailCodePurposeIsEnforcedBeforeUserMutation(t *testing.T) {
|
||||
service := &UserService{}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("password reset rejects a code issued for another purpose", func(t *testing.T) {
|
||||
content := (&schema.EmailCodeContent{
|
||||
SourceType: schema.UnsubscribeSourceType,
|
||||
Email: "user@example.test",
|
||||
}).ToJSONString()
|
||||
|
||||
err := service.UpdatePasswordWhenForgot(ctx, &schema.UserRePassWordRequest{Content: content})
|
||||
if err == nil {
|
||||
t.Fatal("password reset accepted an unsubscribe code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("email activation rejects a password reset code", func(t *testing.T) {
|
||||
content := (&schema.EmailCodeContent{
|
||||
SourceType: schema.PasswordResetSourceType,
|
||||
Email: "user@example.test",
|
||||
}).ToJSONString()
|
||||
|
||||
_, err := service.UserVerifyEmail(ctx, &schema.UserVerifyEmailReq{Content: content})
|
||||
if err == nil {
|
||||
t.Fatal("email activation accepted a password reset code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("change email rejects a password reset code", func(t *testing.T) {
|
||||
content := (&schema.EmailCodeContent{
|
||||
SourceType: schema.PasswordResetSourceType,
|
||||
Email: "user@example.test",
|
||||
}).ToJSONString()
|
||||
|
||||
_, err := service.UserChangeEmailVerify(ctx, content)
|
||||
if err == nil {
|
||||
t.Fatal("change email accepted a password reset code")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyRegistrationVerification(t *testing.T) {
|
||||
t.Run("required sends activation email and leaves email pending", func(t *testing.T) {
|
||||
userInfo := &entity.User{}
|
||||
calls := map[string]int{}
|
||||
|
||||
err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{
|
||||
sendActivationEmail: func() error {
|
||||
calls["sendActivationEmail"]++
|
||||
return nil
|
||||
},
|
||||
activateUser: func() error {
|
||||
calls["activateUser"]++
|
||||
return nil
|
||||
},
|
||||
markEmailAvailable: func() error {
|
||||
calls["markEmailAvailable"]++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
|
||||
assert.Equal(t, 1, calls["sendActivationEmail"])
|
||||
assert.Zero(t, calls["activateUser"])
|
||||
assert.Zero(t, calls["markEmailAvailable"])
|
||||
})
|
||||
|
||||
t.Run("not required activates once and marks email available", func(t *testing.T) {
|
||||
userInfo := &entity.User{}
|
||||
calls := map[string]int{}
|
||||
|
||||
err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{
|
||||
sendActivationEmail: func() error {
|
||||
calls["sendActivationEmail"]++
|
||||
return nil
|
||||
},
|
||||
activateUser: func() error {
|
||||
calls["activateUser"]++
|
||||
return nil
|
||||
},
|
||||
markEmailAvailable: func() error {
|
||||
calls["markEmailAvailable"]++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.EmailStatusAvailable, userInfo.MailStatus)
|
||||
assert.Zero(t, calls["sendActivationEmail"])
|
||||
assert.Equal(t, 1, calls["activateUser"])
|
||||
assert.Equal(t, 1, calls["markEmailAvailable"])
|
||||
})
|
||||
|
||||
t.Run("not required user activation failure falls back to email verification", func(t *testing.T) {
|
||||
userInfo := &entity.User{}
|
||||
calls := map[string]int{}
|
||||
|
||||
err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{
|
||||
sendActivationEmail: func() error {
|
||||
calls["sendActivationEmail"]++
|
||||
return nil
|
||||
},
|
||||
activateUser: func() error {
|
||||
calls["activateUser"]++
|
||||
return errors.New("activate failed")
|
||||
},
|
||||
markEmailAvailable: func() error {
|
||||
calls["markEmailAvailable"]++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
|
||||
assert.Equal(t, 1, calls["sendActivationEmail"])
|
||||
assert.Equal(t, 1, calls["activateUser"])
|
||||
assert.Zero(t, calls["markEmailAvailable"])
|
||||
})
|
||||
|
||||
t.Run("not required email status failure falls back to email verification", func(t *testing.T) {
|
||||
userInfo := &entity.User{}
|
||||
calls := map[string]int{}
|
||||
|
||||
err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{
|
||||
sendActivationEmail: func() error {
|
||||
calls["sendActivationEmail"]++
|
||||
return nil
|
||||
},
|
||||
activateUser: func() error {
|
||||
calls["activateUser"]++
|
||||
return nil
|
||||
},
|
||||
markEmailAvailable: func() error {
|
||||
calls["markEmailAvailable"]++
|
||||
return errors.New("update failed")
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
|
||||
assert.Equal(t, 1, calls["sendActivationEmail"])
|
||||
assert.Equal(t, 1, calls["activateUser"])
|
||||
assert.Equal(t, 1, calls["markEmailAvailable"])
|
||||
})
|
||||
|
||||
t.Run("fallback email failure returns before available email status", func(t *testing.T) {
|
||||
userInfo := &entity.User{}
|
||||
expectedErr := errors.New("email failed")
|
||||
|
||||
err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{
|
||||
sendActivationEmail: func() error {
|
||||
return expectedErr
|
||||
},
|
||||
activateUser: func() error {
|
||||
return errors.New("activate failed")
|
||||
},
|
||||
markEmailAvailable: func() error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, expectedErr)
|
||||
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -40,7 +41,6 @@ import (
|
||||
"github.com/apache/answer/internal/service/siteinfo_common"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
"golang.org/x/net/context"
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/apache/answer/internal/service/permission"
|
||||
"github.com/apache/answer/internal/service/rank"
|
||||
usercommon "github.com/apache/answer/internal/service/user_common"
|
||||
"github.com/apache/answer/pkg/converter"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
@@ -70,7 +71,7 @@ func (ip *ImporterService) NewImporterFunc() plugin.ImporterFunc {
|
||||
}
|
||||
|
||||
func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plugin.QuestionImporterInfo) (err error) {
|
||||
req := &schema.QuestionAdd{}
|
||||
req := newImportedQuestionRequest(questionInfo)
|
||||
errFields := make([]*validator.FormErrorField, 0)
|
||||
// To limit rate, remove the following code from comment: Part 1/2
|
||||
// reject, rejectKey := ipc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req)
|
||||
@@ -94,16 +95,6 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
|
||||
// }
|
||||
// }()
|
||||
req.UserID = userInfo.ID
|
||||
req.Title = questionInfo.Title
|
||||
req.Content = questionInfo.Content
|
||||
req.HTML = "<p>" + questionInfo.Content + "</p>"
|
||||
req.Tags = make([]*schema.TagItem, len(questionInfo.Tags))
|
||||
for i, tag := range questionInfo.Tags {
|
||||
req.Tags[i] = &schema.TagItem{
|
||||
SlugName: tag,
|
||||
DisplayName: tag,
|
||||
}
|
||||
}
|
||||
canList, requireRanks, err := ip.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{
|
||||
permission.QuestionAdd,
|
||||
permission.QuestionEdit,
|
||||
@@ -169,3 +160,19 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
|
||||
log.Info("Add Question Successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newImportedQuestionRequest(questionInfo plugin.QuestionImporterInfo) *schema.QuestionAdd {
|
||||
req := &schema.QuestionAdd{
|
||||
Title: questionInfo.Title,
|
||||
Content: questionInfo.Content,
|
||||
HTML: converter.Markdown2HTML(questionInfo.Content),
|
||||
Tags: make([]*schema.TagItem, len(questionInfo.Tags)),
|
||||
}
|
||||
for i, tag := range questionInfo.Tags {
|
||||
req.Tags[i] = &schema.TagItem{
|
||||
SlugName: tag,
|
||||
DisplayName: tag,
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package importer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/plugin"
|
||||
)
|
||||
|
||||
func TestNewImportedQuestionRequestSanitizesContent(t *testing.T) {
|
||||
request := newImportedQuestionRequest(plugin.QuestionImporterInfo{
|
||||
Title: "Imported question",
|
||||
Content: `<img src=x onerror=alert(1)><script>alert(1)</script>`,
|
||||
Tags: []string{"security"},
|
||||
})
|
||||
|
||||
for _, unsafeContent := range []string{"onerror", "<script"} {
|
||||
if strings.Contains(strings.ToLower(request.HTML), unsafeContent) {
|
||||
t.Fatalf("imported question HTML contains unsafe content %q: %s", unsafeContent, request.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ type ExternalNotificationService struct {
|
||||
notificationQueueService noticequeue.ExternalService
|
||||
userExternalLoginRepo user_external_login.UserExternalLoginRepo
|
||||
siteInfoService siteinfo_common.SiteInfoCommonService
|
||||
newQuestionEmailWorker *newQuestionEmailWorker
|
||||
}
|
||||
|
||||
func NewExternalNotificationService(
|
||||
@@ -67,6 +68,10 @@ func NewExternalNotificationService(
|
||||
userExternalLoginRepo: userExternalLoginRepo,
|
||||
siteInfoService: siteInfoService,
|
||||
}
|
||||
n.newQuestionEmailWorker = newQuestionEmailWorkerWithDefaults(
|
||||
newQuestionNotificationEmailSendInterval,
|
||||
n.sendNewQuestionNotificationEmail,
|
||||
)
|
||||
notificationQueueService.RegisterHandler(n.Handler)
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/apache/answer/pkg/token"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
const defaultNewQuestionEmailWorkerQueueSize = 1024
|
||||
|
||||
const maxNewQuestionEmailWorkerQueueSize = 65536
|
||||
|
||||
const newQuestionEmailWorkerQueueSizeEnv = "NEW_QUESTION_NOTIFICATION_EMAIL_QUEUE_SIZE"
|
||||
|
||||
type newQuestionEmailTask struct {
|
||||
UserIDs []string
|
||||
QuestionTitle string
|
||||
QuestionID string
|
||||
Tags []string
|
||||
TagIDs []string
|
||||
}
|
||||
|
||||
type newQuestionEmailIntervalProvider func() time.Duration
|
||||
|
||||
type newQuestionEmailTimer interface {
|
||||
C() <-chan time.Time
|
||||
Stop()
|
||||
}
|
||||
|
||||
type newQuestionEmailTimerFactory func(time.Duration) newQuestionEmailTimer
|
||||
|
||||
type newQuestionEmailWorker struct {
|
||||
tasks chan newQuestionEmailTask
|
||||
send newQuestionNotificationEmailSender
|
||||
interval newQuestionEmailIntervalProvider
|
||||
timerFactory newQuestionEmailTimerFactory
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newQuestionEmailWorkerWithDefaults(
|
||||
interval newQuestionEmailIntervalProvider,
|
||||
send newQuestionNotificationEmailSender,
|
||||
) *newQuestionEmailWorker {
|
||||
return newQuestionEmailWorkerWithBuffer(
|
||||
interval,
|
||||
send,
|
||||
newRealNewQuestionEmailTimer,
|
||||
newQuestionEmailWorkerQueueSize(),
|
||||
)
|
||||
}
|
||||
|
||||
func newQuestionEmailWorkerQueueSize() int {
|
||||
return parseNewQuestionEmailWorkerQueueSize(os.Getenv(newQuestionEmailWorkerQueueSizeEnv))
|
||||
}
|
||||
|
||||
func parseNewQuestionEmailWorkerQueueSize(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == 0 {
|
||||
return defaultNewQuestionEmailWorkerQueueSize
|
||||
}
|
||||
queueSize, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || queueSize <= 0 {
|
||||
return defaultNewQuestionEmailWorkerQueueSize
|
||||
}
|
||||
if queueSize > int64(maxNewQuestionEmailWorkerQueueSize) {
|
||||
return maxNewQuestionEmailWorkerQueueSize
|
||||
}
|
||||
return int(queueSize)
|
||||
}
|
||||
|
||||
func newQuestionEmailWorkerWithBuffer(
|
||||
interval newQuestionEmailIntervalProvider,
|
||||
send newQuestionNotificationEmailSender,
|
||||
timerFactory newQuestionEmailTimerFactory,
|
||||
bufferSize int,
|
||||
) *newQuestionEmailWorker {
|
||||
if interval == nil {
|
||||
interval = newQuestionNotificationEmailSendInterval
|
||||
}
|
||||
if timerFactory == nil {
|
||||
timerFactory = newRealNewQuestionEmailTimer
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
w := &newQuestionEmailWorker{
|
||||
tasks: make(chan newQuestionEmailTask, bufferSize),
|
||||
send: send,
|
||||
interval: interval,
|
||||
timerFactory: timerFactory,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
w.wg.Add(1)
|
||||
go w.run()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *newQuestionEmailWorker) TryEnqueue(task newQuestionEmailTask) bool {
|
||||
if w == nil {
|
||||
log.Warnf("[new_question_email] worker is nil, dropping new question email task")
|
||||
return false
|
||||
}
|
||||
|
||||
task = copyNewQuestionEmailTask(task)
|
||||
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
if w.closed {
|
||||
log.Warnf("[new_question_email] worker is closed, dropping new question email task for question %s", task.QuestionID)
|
||||
return false
|
||||
}
|
||||
|
||||
if w.ctx == nil {
|
||||
log.Warnf("[new_question_email] worker context is nil, dropping new question email task for question %s", task.QuestionID)
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
log.Warnf("[new_question_email] worker is canceled, dropping new question email task for question %s", task.QuestionID)
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case w.tasks <- task:
|
||||
log.Debugf("[new_question_email] enqueued task for question %s to %d users", task.QuestionID, len(task.UserIDs))
|
||||
return true
|
||||
case <-w.ctx.Done():
|
||||
log.Warnf("[new_question_email] worker canceled while enqueueing task for question %s", task.QuestionID)
|
||||
return false
|
||||
default:
|
||||
log.Warnf("[new_question_email] queue is full, dropping new question email task for question %s", task.QuestionID)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *newQuestionEmailWorker) Close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
if w.cancel != nil {
|
||||
w.cancel()
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
w.wg.Wait()
|
||||
if dropped := w.dropPendingTasks(); dropped > 0 {
|
||||
log.Warnf("[new_question_email] dropped %d pending tasks during shutdown", dropped)
|
||||
}
|
||||
log.Infof("[new_question_email] worker closed")
|
||||
}
|
||||
|
||||
func (w *newQuestionEmailWorker) run() {
|
||||
defer w.wg.Done()
|
||||
|
||||
emailAttemptSent := false
|
||||
for {
|
||||
if w.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
case task := <-w.tasks:
|
||||
if w.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !w.processTask(task, &emailAttemptSent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *newQuestionEmailWorker) processTask(task newQuestionEmailTask, emailAttemptSent *bool) bool {
|
||||
for _, userID := range task.UserIDs {
|
||||
if w.ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
if *emailAttemptSent {
|
||||
interval := w.interval()
|
||||
if interval > 0 && !waitNewQuestionEmailInterval(w.ctx, interval, w.timerFactory) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if w.ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
if w.send == nil {
|
||||
log.Errorf("[new_question_email] sender is nil, dropping email attempt for user %s question %s", userID, task.QuestionID)
|
||||
*emailAttemptSent = true
|
||||
continue
|
||||
}
|
||||
w.send(w.ctx, userID, task.newRawData())
|
||||
*emailAttemptSent = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *newQuestionEmailWorker) dropPendingTasks() int {
|
||||
dropped := 0
|
||||
for {
|
||||
select {
|
||||
case <-w.tasks:
|
||||
dropped++
|
||||
default:
|
||||
return dropped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitNewQuestionEmailInterval(
|
||||
ctx context.Context,
|
||||
interval time.Duration,
|
||||
timerFactory newQuestionEmailTimerFactory,
|
||||
) bool {
|
||||
if interval <= 0 {
|
||||
return true
|
||||
}
|
||||
if timerFactory == nil {
|
||||
timerFactory = newRealNewQuestionEmailTimer
|
||||
}
|
||||
timer := timerFactory(interval)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C():
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (task newQuestionEmailTask) newRawData() *schema.NewQuestionTemplateRawData {
|
||||
return &schema.NewQuestionTemplateRawData{
|
||||
QuestionTitle: task.QuestionTitle,
|
||||
QuestionID: task.QuestionID,
|
||||
UnsubscribeCode: token.GenerateToken(),
|
||||
Tags: copyStringSlice(task.Tags),
|
||||
TagIDs: copyStringSlice(task.TagIDs),
|
||||
}
|
||||
}
|
||||
|
||||
func newQuestionEmailTaskFromRawData(
|
||||
userIDs []string,
|
||||
rawData *schema.NewQuestionTemplateRawData,
|
||||
) newQuestionEmailTask {
|
||||
if rawData == nil {
|
||||
return newQuestionEmailTask{UserIDs: copyStringSlice(userIDs)}
|
||||
}
|
||||
return newQuestionEmailTask{
|
||||
UserIDs: copyStringSlice(userIDs),
|
||||
QuestionTitle: rawData.QuestionTitle,
|
||||
QuestionID: rawData.QuestionID,
|
||||
Tags: copyStringSlice(rawData.Tags),
|
||||
TagIDs: copyStringSlice(rawData.TagIDs),
|
||||
}
|
||||
}
|
||||
|
||||
func copyNewQuestionEmailTask(task newQuestionEmailTask) newQuestionEmailTask {
|
||||
task.UserIDs = copyStringSlice(task.UserIDs)
|
||||
task.Tags = copyStringSlice(task.Tags)
|
||||
task.TagIDs = copyStringSlice(task.TagIDs)
|
||||
return task
|
||||
}
|
||||
|
||||
func copyStringSlice(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make([]string, len(values))
|
||||
copy(copied, values)
|
||||
return copied
|
||||
}
|
||||
|
||||
type realNewQuestionEmailTimer struct {
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
func newRealNewQuestionEmailTimer(interval time.Duration) newQuestionEmailTimer {
|
||||
return &realNewQuestionEmailTimer{timer: time.NewTimer(interval)}
|
||||
}
|
||||
|
||||
func (t *realNewQuestionEmailTimer) C() <-chan time.Time {
|
||||
return t.timer.C
|
||||
}
|
||||
|
||||
func (t *realNewQuestionEmailTimer) Stop() {
|
||||
if !t.timer.Stop() {
|
||||
select {
|
||||
case <-t.timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
func TestParseNewQuestionEmailWorkerQueueSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
value: "",
|
||||
want: defaultNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
{
|
||||
name: "whitespace",
|
||||
value: " ",
|
||||
want: defaultNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
value: "invalid",
|
||||
want: defaultNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
{
|
||||
name: "zero",
|
||||
value: "0",
|
||||
want: defaultNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
{
|
||||
name: "negative",
|
||||
value: "-1",
|
||||
want: defaultNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
{
|
||||
name: "parse int overflow",
|
||||
value: "9223372036854775808",
|
||||
want: defaultNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
{
|
||||
name: "positive",
|
||||
value: "2048",
|
||||
want: 2048,
|
||||
},
|
||||
{
|
||||
name: "max",
|
||||
value: "65536",
|
||||
want: maxNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
{
|
||||
name: "above max",
|
||||
value: "65537",
|
||||
want: maxNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseNewQuestionEmailWorkerQueueSize(tt.value)
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseNewQuestionEmailWorkerQueueSize(%q) = %d, want %d", tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerQueueSizeUnsetEnv(t *testing.T) {
|
||||
setNewQuestionEmailWorkerQueueSizeEnv(t, "", false)
|
||||
|
||||
got := newQuestionEmailWorkerQueueSize()
|
||||
if got != defaultNewQuestionEmailWorkerQueueSize {
|
||||
t.Fatalf("newQuestionEmailWorkerQueueSize() = %d, want %d",
|
||||
got, defaultNewQuestionEmailWorkerQueueSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerWithDefaultsUsesQueueSizeEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "configured",
|
||||
value: "2048",
|
||||
want: 2048,
|
||||
},
|
||||
{
|
||||
name: "invalid uses default",
|
||||
value: "invalid",
|
||||
want: defaultNewQuestionEmailWorkerQueueSize,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setNewQuestionEmailWorkerQueueSizeEnv(t, tt.value, true)
|
||||
|
||||
worker := newQuestionEmailWorkerWithDefaults(func() time.Duration { return 0 }, nil)
|
||||
defer worker.Close()
|
||||
|
||||
if got := cap(worker.tasks); got != tt.want {
|
||||
t.Fatalf("cap(worker.tasks) = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerDelaysBetweenAttempts(t *testing.T) {
|
||||
timerFactory := newFakeNewQuestionEmailTimerFactory()
|
||||
sendCh := make(chan newQuestionEmailSendEvent, 2)
|
||||
worker := newQuestionEmailWorkerWithBuffer(
|
||||
func() time.Duration { return 3 * time.Second },
|
||||
newQuestionEmailSendRecorder(sendCh),
|
||||
timerFactory.New,
|
||||
2,
|
||||
)
|
||||
defer worker.Close()
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2")) {
|
||||
t.Fatalf("TryEnqueue() = false, want true")
|
||||
}
|
||||
|
||||
first := receiveNewQuestionEmailSend(t, sendCh)
|
||||
if first.userID != "user-1" {
|
||||
t.Fatalf("first send user = %s, want user-1", first.userID)
|
||||
}
|
||||
timer := timerFactory.WaitForTimer(t)
|
||||
assertNoNewQuestionEmailSend(t, sendCh)
|
||||
timer.Fire()
|
||||
|
||||
second := receiveNewQuestionEmailSend(t, sendCh)
|
||||
if second.userID != "user-2" {
|
||||
t.Fatalf("second send user = %s, want user-2", second.userID)
|
||||
}
|
||||
assertUniqueNewQuestionUnsubscribeCodes(t, []string{
|
||||
first.rawData.UnsubscribeCode,
|
||||
second.rawData.UnsubscribeCode,
|
||||
})
|
||||
if got := timerFactory.Durations(); !reflect.DeepEqual(got, []time.Duration{3 * time.Second}) {
|
||||
t.Fatalf("timer durations = %v, want [3s]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerDelayContinuesAcrossTaskBoundaries(t *testing.T) {
|
||||
timerFactory := newFakeNewQuestionEmailTimerFactory()
|
||||
sendCh := make(chan newQuestionEmailSendEvent, 2)
|
||||
worker := newQuestionEmailWorkerWithBuffer(
|
||||
func() time.Duration { return 5 * time.Second },
|
||||
newQuestionEmailSendRecorder(sendCh),
|
||||
timerFactory.New,
|
||||
2,
|
||||
)
|
||||
defer worker.Close()
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1")) {
|
||||
t.Fatalf("TryEnqueue() task 1 = false, want true")
|
||||
}
|
||||
first := receiveNewQuestionEmailSend(t, sendCh)
|
||||
if first.userID != "user-1" {
|
||||
t.Fatalf("first send user = %s, want user-1", first.userID)
|
||||
}
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-2", "user-2")) {
|
||||
t.Fatalf("TryEnqueue() task 2 = false, want true")
|
||||
}
|
||||
timer := timerFactory.WaitForTimer(t)
|
||||
assertNoNewQuestionEmailSend(t, sendCh)
|
||||
timer.Fire()
|
||||
|
||||
second := receiveNewQuestionEmailSend(t, sendCh)
|
||||
if second.userID != "user-2" {
|
||||
t.Fatalf("second send user = %s, want user-2", second.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerZeroIntervalSendsWithoutTimers(t *testing.T) {
|
||||
var timerCount int
|
||||
sendCh := make(chan newQuestionEmailSendEvent, 3)
|
||||
worker := newQuestionEmailWorkerWithBuffer(
|
||||
func() time.Duration { return 0 },
|
||||
newQuestionEmailSendRecorder(sendCh),
|
||||
func(time.Duration) newQuestionEmailTimer {
|
||||
timerCount++
|
||||
return newFakeNewQuestionEmailTimer()
|
||||
},
|
||||
2,
|
||||
)
|
||||
defer worker.Close()
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2", "user-3")) {
|
||||
t.Fatalf("TryEnqueue() = false, want true")
|
||||
}
|
||||
|
||||
gotUsers := []string{
|
||||
receiveNewQuestionEmailSend(t, sendCh).userID,
|
||||
receiveNewQuestionEmailSend(t, sendCh).userID,
|
||||
receiveNewQuestionEmailSend(t, sendCh).userID,
|
||||
}
|
||||
if !reflect.DeepEqual(gotUsers, []string{"user-1", "user-2", "user-3"}) {
|
||||
t.Fatalf("send users = %v, want [user-1 user-2 user-3]", gotUsers)
|
||||
}
|
||||
if timerCount != 0 {
|
||||
t.Fatalf("timer count = %d, want 0", timerCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerCloseCancelsPendingWaitAndDropsQueuedTasks(t *testing.T) {
|
||||
timerFactory := newFakeNewQuestionEmailTimerFactory()
|
||||
sendCh := make(chan newQuestionEmailSendEvent, 3)
|
||||
worker := newQuestionEmailWorkerWithBuffer(
|
||||
func() time.Duration { return time.Hour },
|
||||
newQuestionEmailSendRecorder(sendCh),
|
||||
timerFactory.New,
|
||||
2,
|
||||
)
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2")) {
|
||||
t.Fatalf("TryEnqueue() task 1 = false, want true")
|
||||
}
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-2", "user-3")) {
|
||||
t.Fatalf("TryEnqueue() task 2 = false, want true")
|
||||
}
|
||||
|
||||
first := receiveNewQuestionEmailSend(t, sendCh)
|
||||
if first.userID != "user-1" {
|
||||
t.Fatalf("first send user = %s, want user-1", first.userID)
|
||||
}
|
||||
timer := timerFactory.WaitForTimer(t)
|
||||
|
||||
worker.Close()
|
||||
timer.AssertStopped(t)
|
||||
assertNoNewQuestionEmailSend(t, sendCh)
|
||||
if got := len(worker.tasks); got != 0 {
|
||||
t.Fatalf("pending tasks after Close() = %d, want 0", got)
|
||||
}
|
||||
if worker.TryEnqueue(newQuestionEmailWorkerTask("question-3", "user-4")) {
|
||||
t.Fatalf("TryEnqueue() after Close() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerProcessesSerially(t *testing.T) {
|
||||
entered := make(chan string, 2)
|
||||
releaseFirst := make(chan struct{})
|
||||
worker := newQuestionEmailWorkerWithBuffer(
|
||||
func() time.Duration { return 0 },
|
||||
func(_ context.Context, userID string, _ *schema.NewQuestionTemplateRawData) {
|
||||
entered <- userID
|
||||
if userID == "user-1" {
|
||||
<-releaseFirst
|
||||
}
|
||||
},
|
||||
nil,
|
||||
2,
|
||||
)
|
||||
defer worker.Close()
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2")) {
|
||||
t.Fatalf("TryEnqueue() = false, want true")
|
||||
}
|
||||
if got := receiveString(t, entered); got != "user-1" {
|
||||
t.Fatalf("first send user = %s, want user-1", got)
|
||||
}
|
||||
assertNoString(t, entered)
|
||||
|
||||
close(releaseFirst)
|
||||
if got := receiveString(t, entered); got != "user-2" {
|
||||
t.Fatalf("second send user = %s, want user-2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerBuildsFreshRawDataPerAttempt(t *testing.T) {
|
||||
sendCh := make(chan newQuestionEmailSendEvent, 2)
|
||||
worker := newQuestionEmailWorkerWithBuffer(
|
||||
func() time.Duration { return 0 },
|
||||
func(_ context.Context, userID string, rawData *schema.NewQuestionTemplateRawData) {
|
||||
if rawData.QuestionAuthorUserID != "" {
|
||||
t.Errorf("QuestionAuthorUserID = %q, want empty", rawData.QuestionAuthorUserID)
|
||||
}
|
||||
if userID == "user-1" {
|
||||
rawData.Tags[0] = "mutated"
|
||||
rawData.TagIDs[0] = "mutated"
|
||||
}
|
||||
sendCh <- newQuestionEmailSendEvent{userID: userID, rawData: rawData}
|
||||
},
|
||||
nil,
|
||||
2,
|
||||
)
|
||||
defer worker.Close()
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailTask{
|
||||
UserIDs: []string{"user-1", "user-2"},
|
||||
QuestionTitle: "Question",
|
||||
QuestionID: "question-1",
|
||||
Tags: []string{"go"},
|
||||
TagIDs: []string{"tag-1"},
|
||||
}) {
|
||||
t.Fatalf("TryEnqueue() = false, want true")
|
||||
}
|
||||
|
||||
first := receiveNewQuestionEmailSend(t, sendCh)
|
||||
second := receiveNewQuestionEmailSend(t, sendCh)
|
||||
if first.rawData.UnsubscribeCode == "" || second.rawData.UnsubscribeCode == "" {
|
||||
t.Fatalf("unsubscribe codes must be non-empty: %q %q",
|
||||
first.rawData.UnsubscribeCode, second.rawData.UnsubscribeCode)
|
||||
}
|
||||
if first.rawData.UnsubscribeCode == second.rawData.UnsubscribeCode {
|
||||
t.Fatalf("unsubscribe codes must be unique, both were %q", first.rawData.UnsubscribeCode)
|
||||
}
|
||||
if !reflect.DeepEqual(second.rawData.Tags, []string{"go"}) ||
|
||||
!reflect.DeepEqual(second.rawData.TagIDs, []string{"tag-1"}) {
|
||||
t.Fatalf("second raw data tags = %v/%v, want original values",
|
||||
second.rawData.Tags, second.rawData.TagIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerTryEnqueueCopiesTaskAndFailsFast(t *testing.T) {
|
||||
worker := newUnstartedNewQuestionEmailWorkerForTest()
|
||||
task := newQuestionEmailTask{
|
||||
UserIDs: []string{"user-1"},
|
||||
QuestionTitle: "Question",
|
||||
QuestionID: "question-1",
|
||||
Tags: []string{"go"},
|
||||
TagIDs: []string{"tag-1"},
|
||||
}
|
||||
if !worker.TryEnqueue(task) {
|
||||
t.Fatalf("TryEnqueue() = false, want true")
|
||||
}
|
||||
task.UserIDs[0] = "mutated-user"
|
||||
task.Tags[0] = "mutated-tag"
|
||||
task.TagIDs[0] = "mutated-tag-id"
|
||||
|
||||
queuedTask := <-worker.tasks
|
||||
if !reflect.DeepEqual(queuedTask.UserIDs, []string{"user-1"}) ||
|
||||
!reflect.DeepEqual(queuedTask.Tags, []string{"go"}) ||
|
||||
!reflect.DeepEqual(queuedTask.TagIDs, []string{"tag-1"}) {
|
||||
t.Fatalf("queued task was mutated: %+v", queuedTask)
|
||||
}
|
||||
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-2", "user-2")) {
|
||||
t.Fatalf("TryEnqueue() refill = false, want true")
|
||||
}
|
||||
if worker.TryEnqueue(newQuestionEmailWorkerTask("question-3", "user-3")) {
|
||||
t.Fatalf("TryEnqueue() with full queue = true, want false")
|
||||
}
|
||||
|
||||
worker.Close()
|
||||
if worker.TryEnqueue(newQuestionEmailWorkerTask("question-4", "user-4")) {
|
||||
t.Fatalf("TryEnqueue() after Close() = true, want false")
|
||||
}
|
||||
|
||||
canceledWorker := newUnstartedNewQuestionEmailWorkerForTest()
|
||||
canceledWorker.cancel()
|
||||
if canceledWorker.TryEnqueue(newQuestionEmailWorkerTask("question-5", "user-5")) {
|
||||
t.Fatalf("TryEnqueue() after cancel = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewQuestionEmailWorkerTryEnqueueConcurrentClose(t *testing.T) {
|
||||
const (
|
||||
iterations = 100
|
||||
senders = 32
|
||||
)
|
||||
|
||||
for iteration := range iterations {
|
||||
worker := newUnstartedNewQuestionEmailWorkerForTest()
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("already-queued", "queued-user")) {
|
||||
t.Fatalf("iteration %d: pre-fill TryEnqueue() = false, want true", iteration)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
ready := make(chan struct{}, senders)
|
||||
panicCh := make(chan any, senders)
|
||||
var closeObserved atomic.Bool
|
||||
var acceptedAfterCloseObserved atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range senders {
|
||||
//nolint:modernize // CI uses Go 1.23, which does not support WaitGroup.Go.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
panicCh <- recovered
|
||||
}
|
||||
}()
|
||||
|
||||
ready <- struct{}{}
|
||||
<-start
|
||||
for {
|
||||
accepted := worker.TryEnqueue(newQuestionEmailWorkerTask("question", "user"))
|
||||
if accepted && closeObserved.Load() {
|
||||
acceptedAfterCloseObserved.Add(1)
|
||||
}
|
||||
if closeObserved.Load() {
|
||||
return
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for range senders {
|
||||
<-ready
|
||||
}
|
||||
|
||||
closeDoneObserved := make(chan struct{})
|
||||
go func() {
|
||||
<-worker.ctx.Done()
|
||||
closeObserved.Store(true)
|
||||
close(closeDoneObserved)
|
||||
}()
|
||||
|
||||
close(start)
|
||||
runtime.Gosched()
|
||||
|
||||
closeDone := make(chan struct{})
|
||||
go func() {
|
||||
worker.Close()
|
||||
close(closeDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-closeDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("iteration %d: Close() did not return", iteration)
|
||||
}
|
||||
select {
|
||||
case <-closeDoneObserved:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("iteration %d: close was not observed", iteration)
|
||||
}
|
||||
|
||||
wgDone := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(wgDone)
|
||||
}()
|
||||
select {
|
||||
case <-wgDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("iteration %d: TryEnqueue goroutines did not return", iteration)
|
||||
}
|
||||
|
||||
select {
|
||||
case recovered := <-panicCh:
|
||||
t.Fatalf("iteration %d: TryEnqueue panicked during Close(): %v", iteration, recovered)
|
||||
default:
|
||||
}
|
||||
if got := acceptedAfterCloseObserved.Load(); got != 0 {
|
||||
t.Fatalf("iteration %d: accepted %d enqueue attempts after close was observed, want 0",
|
||||
iteration, got)
|
||||
}
|
||||
if worker.TryEnqueue(newQuestionEmailWorkerTask("after-close", "user")) {
|
||||
t.Fatalf("iteration %d: TryEnqueue() after Close() = true, want false", iteration)
|
||||
}
|
||||
if got := len(worker.tasks); got != 0 {
|
||||
t.Fatalf("iteration %d: pending tasks after Close() = %d, want 0", iteration, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitNewQuestionEmailIntervalCancel(t *testing.T) {
|
||||
timerFactory := newFakeNewQuestionEmailTimerFactory()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
done <- waitNewQuestionEmailInterval(ctx, time.Minute, timerFactory.New)
|
||||
}()
|
||||
|
||||
timer := timerFactory.WaitForTimer(t)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got {
|
||||
t.Fatalf("waitNewQuestionEmailInterval() = true, want false")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("waitNewQuestionEmailInterval() did not return after cancellation")
|
||||
}
|
||||
timer.AssertStopped(t)
|
||||
}
|
||||
|
||||
type newQuestionEmailSendEvent struct {
|
||||
userID string
|
||||
rawData *schema.NewQuestionTemplateRawData
|
||||
}
|
||||
|
||||
func newQuestionEmailSendRecorder(sendCh chan<- newQuestionEmailSendEvent) newQuestionNotificationEmailSender {
|
||||
return func(_ context.Context, userID string, rawData *schema.NewQuestionTemplateRawData) {
|
||||
sendCh <- newQuestionEmailSendEvent{userID: userID, rawData: rawData}
|
||||
}
|
||||
}
|
||||
|
||||
func newQuestionEmailWorkerTask(questionID string, userIDs ...string) newQuestionEmailTask {
|
||||
return newQuestionEmailTask{
|
||||
UserIDs: userIDs,
|
||||
QuestionTitle: "Question",
|
||||
QuestionID: questionID,
|
||||
Tags: []string{"go"},
|
||||
TagIDs: []string{"tag-1"},
|
||||
}
|
||||
}
|
||||
|
||||
func newUnstartedNewQuestionEmailWorkerForTest() *newQuestionEmailWorker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &newQuestionEmailWorker{
|
||||
tasks: make(chan newQuestionEmailTask, 1),
|
||||
interval: func() time.Duration { return 0 },
|
||||
timerFactory: newRealNewQuestionEmailTimer,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func setNewQuestionEmailWorkerQueueSizeEnv(t *testing.T, value string, set bool) {
|
||||
t.Helper()
|
||||
|
||||
oldValue, oldSet := os.LookupEnv(newQuestionEmailWorkerQueueSizeEnv)
|
||||
if set {
|
||||
if err := os.Setenv(newQuestionEmailWorkerQueueSizeEnv, value); err != nil {
|
||||
t.Fatalf("set env: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Unsetenv(newQuestionEmailWorkerQueueSizeEnv); err != nil {
|
||||
t.Fatalf("unset env: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if oldSet {
|
||||
_ = os.Setenv(newQuestionEmailWorkerQueueSizeEnv, oldValue)
|
||||
} else {
|
||||
_ = os.Unsetenv(newQuestionEmailWorkerQueueSizeEnv)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func receiveNewQuestionEmailSend(t *testing.T, sendCh <-chan newQuestionEmailSendEvent) newQuestionEmailSendEvent {
|
||||
t.Helper()
|
||||
select {
|
||||
case event := <-sendCh:
|
||||
return event
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for new question email send")
|
||||
return newQuestionEmailSendEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoNewQuestionEmailSend(t *testing.T, sendCh <-chan newQuestionEmailSendEvent) {
|
||||
t.Helper()
|
||||
select {
|
||||
case event := <-sendCh:
|
||||
t.Fatalf("unexpected new question email send: %+v", event)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func receiveString(t *testing.T, ch <-chan string) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case value := <-ch:
|
||||
return value
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for string")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoString(t *testing.T, ch <-chan string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case value := <-ch:
|
||||
t.Fatalf("unexpected string: %s", value)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type fakeNewQuestionEmailTimerFactory struct {
|
||||
timers chan *fakeNewQuestionEmailTimer
|
||||
mu sync.Mutex
|
||||
durations []time.Duration
|
||||
}
|
||||
|
||||
func newFakeNewQuestionEmailTimerFactory() *fakeNewQuestionEmailTimerFactory {
|
||||
return &fakeNewQuestionEmailTimerFactory{
|
||||
timers: make(chan *fakeNewQuestionEmailTimer, 16),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeNewQuestionEmailTimerFactory) New(duration time.Duration) newQuestionEmailTimer {
|
||||
timer := newFakeNewQuestionEmailTimer()
|
||||
|
||||
f.mu.Lock()
|
||||
f.durations = append(f.durations, duration)
|
||||
f.mu.Unlock()
|
||||
|
||||
f.timers <- timer
|
||||
return timer
|
||||
}
|
||||
|
||||
func (f *fakeNewQuestionEmailTimerFactory) WaitForTimer(t *testing.T) *fakeNewQuestionEmailTimer {
|
||||
t.Helper()
|
||||
select {
|
||||
case timer := <-f.timers:
|
||||
return timer
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for timer")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeNewQuestionEmailTimerFactory) Durations() []time.Duration {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
durations := make([]time.Duration, len(f.durations))
|
||||
copy(durations, f.durations)
|
||||
return durations
|
||||
}
|
||||
|
||||
type fakeNewQuestionEmailTimer struct {
|
||||
ch chan time.Time
|
||||
stopped chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newFakeNewQuestionEmailTimer() *fakeNewQuestionEmailTimer {
|
||||
return &fakeNewQuestionEmailTimer{
|
||||
ch: make(chan time.Time, 1),
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *fakeNewQuestionEmailTimer) C() <-chan time.Time {
|
||||
return t.ch
|
||||
}
|
||||
|
||||
func (t *fakeNewQuestionEmailTimer) Stop() {
|
||||
t.once.Do(func() {
|
||||
close(t.stopped)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *fakeNewQuestionEmailTimer) Fire() {
|
||||
t.ch <- time.Now()
|
||||
}
|
||||
|
||||
func (t *fakeNewQuestionEmailTimer) AssertStopped(tb testing.TB) {
|
||||
tb.Helper()
|
||||
select {
|
||||
case <-t.stopped:
|
||||
case <-time.After(time.Second):
|
||||
tb.Fatalf("timer was not stopped")
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/apache/answer/internal/base/translator"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/apache/answer/pkg/display"
|
||||
"github.com/apache/answer/pkg/token"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/jinzhu/copier"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
@@ -50,25 +49,42 @@ func (ns *ExternalNotificationService) handleNewQuestionNotification(ctx context
|
||||
}
|
||||
log.Debugf("get subscribers %d for question %s", len(subscribers), msg.NewQuestionTemplateRawData.QuestionID)
|
||||
|
||||
ns.syncNewQuestionNotificationToPlugin(ctx, msg)
|
||||
ns.enqueueNewQuestionNotificationEmails(subscribers, msg.NewQuestionTemplateRawData)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *ExternalNotificationService) enqueueNewQuestionNotificationEmails(
|
||||
subscribers []*NewQuestionSubscriber,
|
||||
rawData *schema.NewQuestionTemplateRawData,
|
||||
) {
|
||||
task := newQuestionEmailTaskFromRawData(collectNewQuestionNotificationEmailUserIDs(subscribers), rawData)
|
||||
if len(task.UserIDs) == 0 {
|
||||
return
|
||||
}
|
||||
if ns.newQuestionEmailWorker == nil {
|
||||
log.Warnf("[new_question_email] worker is nil, dropping task for question %s", task.QuestionID)
|
||||
return
|
||||
}
|
||||
if !ns.newQuestionEmailWorker.TryEnqueue(task) {
|
||||
log.Warnf("[new_question_email] failed to enqueue task for question %s", task.QuestionID)
|
||||
}
|
||||
}
|
||||
|
||||
func collectNewQuestionNotificationEmailUserIDs(subscribers []*NewQuestionSubscriber) []string {
|
||||
userIDs := make([]string, 0, len(subscribers))
|
||||
for _, subscriber := range subscribers {
|
||||
if subscriber == nil {
|
||||
continue
|
||||
}
|
||||
for _, channel := range subscriber.Channels {
|
||||
if !channel.Enable {
|
||||
if channel == nil || !channel.Enable || channel.Key != constant.EmailChannel {
|
||||
continue
|
||||
}
|
||||
if channel.Key == constant.EmailChannel {
|
||||
ns.sendNewQuestionNotificationEmail(ctx, subscriber.UserID, &schema.NewQuestionTemplateRawData{
|
||||
QuestionTitle: msg.NewQuestionTemplateRawData.QuestionTitle,
|
||||
QuestionID: msg.NewQuestionTemplateRawData.QuestionID,
|
||||
UnsubscribeCode: token.GenerateToken(),
|
||||
Tags: msg.NewQuestionTemplateRawData.Tags,
|
||||
TagIDs: msg.NewQuestionTemplateRawData.TagIDs,
|
||||
})
|
||||
}
|
||||
userIDs = append(userIDs, subscriber.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
ns.syncNewQuestionNotificationToPlugin(ctx, msg)
|
||||
return nil
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func (ns *ExternalNotificationService) getNewQuestionSubscribers(ctx context.Context, msg *schema.ExternalNotificationMsg) (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
const newQuestionNotificationEmailSendIntervalEnv = "NEW_QUESTION_NOTIFICATION_EMAIL_SEND_INTERVAL_SECONDS"
|
||||
|
||||
const maxNewQuestionNotificationEmailSendInterval = 5 * time.Minute
|
||||
|
||||
const maxNewQuestionNotificationEmailSendIntervalSeconds = int64(maxNewQuestionNotificationEmailSendInterval / time.Second)
|
||||
|
||||
type newQuestionNotificationEmailSender func(context.Context, string, *schema.NewQuestionTemplateRawData)
|
||||
|
||||
func newQuestionNotificationEmailSendInterval() time.Duration {
|
||||
return parseNewQuestionNotificationEmailSendInterval(os.Getenv(newQuestionNotificationEmailSendIntervalEnv))
|
||||
}
|
||||
|
||||
func parseNewQuestionNotificationEmailSendInterval(value string) time.Duration {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == 0 {
|
||||
return 0
|
||||
}
|
||||
seconds, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || seconds < 0 {
|
||||
return 0
|
||||
}
|
||||
if seconds > maxNewQuestionNotificationEmailSendIntervalSeconds {
|
||||
return maxNewQuestionNotificationEmailSendInterval
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
basedata "github.com/apache/answer/internal/base/data"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/apache/answer/internal/service/config"
|
||||
"github.com/apache/answer/internal/service/export"
|
||||
"github.com/apache/answer/internal/service/mock"
|
||||
"github.com/apache/answer/plugin"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestNewQuestionNotificationEmailSendInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
set bool
|
||||
want time.Duration
|
||||
}{
|
||||
{
|
||||
name: "unset",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
value: "",
|
||||
set: true,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "positive integer",
|
||||
value: "5",
|
||||
set: true,
|
||||
want: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "positive integer with whitespace",
|
||||
value: " 5 ",
|
||||
set: true,
|
||||
want: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
value: "not-a-number",
|
||||
set: true,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "negative",
|
||||
value: "-1",
|
||||
set: true,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "whitespace",
|
||||
value: " ",
|
||||
set: true,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "above max clamps to max",
|
||||
value: "301",
|
||||
set: true,
|
||||
want: maxNewQuestionNotificationEmailSendInterval,
|
||||
},
|
||||
{
|
||||
name: "duration overflow clamps to max",
|
||||
value: "9223372037",
|
||||
set: true,
|
||||
want: maxNewQuestionNotificationEmailSendInterval,
|
||||
},
|
||||
{
|
||||
name: "parse int overflow",
|
||||
value: "9223372036854775808",
|
||||
set: true,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setNewQuestionNotificationEmailSendIntervalEnv(t, tt.value, tt.set)
|
||||
|
||||
got := newQuestionNotificationEmailSendInterval()
|
||||
if got != tt.want {
|
||||
t.Fatalf("newQuestionNotificationEmailSendInterval() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewQuestionNotificationEnqueuesEmailTask(t *testing.T) {
|
||||
setNewQuestionNotificationEmailSendIntervalEnv(t, "0", true)
|
||||
|
||||
cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{})
|
||||
if err != nil {
|
||||
t.Fatalf("new cache: %v", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
siteInfoService := mock.NewMockSiteInfoCommonService(ctrl)
|
||||
siteInfoService.EXPECT().GetSiteGeneral(gomock.Any()).Return(&schema.SiteGeneralResp{
|
||||
Name: "Answer",
|
||||
SiteUrl: "https://answer.test",
|
||||
ContactEmail: "support@answer.test",
|
||||
}, nil).AnyTimes()
|
||||
siteInfoService.EXPECT().GetSiteSeo(gomock.Any()).Return(&schema.SiteSeoResp{
|
||||
Permalink: constant.PermalinkQuestionIDAndTitle,
|
||||
}, nil).AnyTimes()
|
||||
|
||||
emailRepo := &newQuestionNotificationTestEmailRepo{
|
||||
codesByUserID: make(map[string][]string),
|
||||
}
|
||||
notificationConfigRepo := &newQuestionNotificationTestUserNotificationConfigRepo{
|
||||
followedTagConfigs: map[string]*entity.UserNotificationConfig{
|
||||
"tag-user": newQuestionNotificationConfig(
|
||||
"tag-user", constant.AllNewQuestionForFollowingTagsSource, true),
|
||||
"dup-user": newQuestionNotificationConfig(
|
||||
"dup-user", constant.AllNewQuestionForFollowingTagsSource, true),
|
||||
"author": newQuestionNotificationConfig(
|
||||
"author", constant.AllNewQuestionForFollowingTagsSource, true),
|
||||
},
|
||||
allQuestionConfigs: []*entity.UserNotificationConfig{
|
||||
newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, true),
|
||||
newQuestionNotificationConfig("dup-user", constant.AllNewQuestionSource, true),
|
||||
newQuestionNotificationConfig("author", constant.AllNewQuestionSource, true),
|
||||
},
|
||||
}
|
||||
service := &ExternalNotificationService{
|
||||
data: &basedata.Data{
|
||||
Cache: cache,
|
||||
},
|
||||
userNotificationConfigRepo: notificationConfigRepo,
|
||||
followRepo: &newQuestionNotificationTestFollowRepo{
|
||||
followersByObjectID: map[string][]string{
|
||||
"tag-1": {"tag-user", "dup-user", "author"},
|
||||
},
|
||||
},
|
||||
emailService: export.NewEmailService(
|
||||
config.NewConfigService(newQuestionNotificationTestConfigRepo{}),
|
||||
emailRepo,
|
||||
siteInfoService,
|
||||
),
|
||||
userRepo: &newQuestionNotificationTestUserRepo{
|
||||
users: map[string]*entity.User{
|
||||
"tag-user": newQuestionNotificationTestUser("tag-user"),
|
||||
"dup-user": newQuestionNotificationTestUser("dup-user"),
|
||||
"all-user": newQuestionNotificationTestUser("all-user"),
|
||||
"author": newQuestionNotificationTestUser("author"),
|
||||
},
|
||||
},
|
||||
siteInfoService: siteInfoService,
|
||||
}
|
||||
service.newQuestionEmailWorker = newUnstartedNewQuestionEmailWorkerForTest()
|
||||
|
||||
err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{
|
||||
NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{
|
||||
QuestionTitle: "New question",
|
||||
QuestionID: "1",
|
||||
QuestionAuthorUserID: "author",
|
||||
Tags: []string{"go"},
|
||||
TagIDs: []string{"tag-1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handleNewQuestionNotification() error = %v", err)
|
||||
}
|
||||
|
||||
var task newQuestionEmailTask
|
||||
select {
|
||||
case task = <-service.newQuestionEmailWorker.tasks:
|
||||
default:
|
||||
t.Fatalf("expected enqueued new question email task")
|
||||
}
|
||||
|
||||
wantUsers := []string{"all-user", "dup-user", "tag-user"}
|
||||
assertStringSet(t, task.UserIDs, wantUsers)
|
||||
if task.QuestionTitle != "New question" || task.QuestionID != "1" {
|
||||
t.Fatalf("task question data = %+v", task)
|
||||
}
|
||||
if !reflect.DeepEqual(task.Tags, []string{"go"}) || !reflect.DeepEqual(task.TagIDs, []string{"tag-1"}) {
|
||||
t.Fatalf("task tags = %v/%v", task.Tags, task.TagIDs)
|
||||
}
|
||||
if len(emailRepo.codesByUserID) > 0 {
|
||||
t.Fatalf("handler sent emails synchronously: %v", emailRepo.codesByUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewQuestionNotificationSkipsEnqueueWithoutEnabledEmailAttempts(t *testing.T) {
|
||||
cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{})
|
||||
if err != nil {
|
||||
t.Fatalf("new cache: %v", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
service := &ExternalNotificationService{
|
||||
data: &basedata.Data{Cache: cache},
|
||||
userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{
|
||||
followedTagConfigs: map[string]*entity.UserNotificationConfig{
|
||||
"tag-user": newQuestionNotificationConfig(
|
||||
"tag-user", constant.AllNewQuestionForFollowingTagsSource, false),
|
||||
},
|
||||
allQuestionConfigs: []*entity.UserNotificationConfig{
|
||||
newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, false),
|
||||
},
|
||||
},
|
||||
followRepo: &newQuestionNotificationTestFollowRepo{
|
||||
followersByObjectID: map[string][]string{"tag-1": {"tag-user"}},
|
||||
},
|
||||
userRepo: &newQuestionNotificationTestUserRepo{
|
||||
users: map[string]*entity.User{
|
||||
"tag-user": newQuestionNotificationTestUser("tag-user"),
|
||||
"all-user": newQuestionNotificationTestUser("all-user"),
|
||||
},
|
||||
},
|
||||
newQuestionEmailWorker: newUnstartedNewQuestionEmailWorkerForTest(),
|
||||
}
|
||||
|
||||
err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{
|
||||
NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{
|
||||
QuestionTitle: "New question",
|
||||
QuestionID: "1",
|
||||
Tags: []string{"go"},
|
||||
TagIDs: []string{"tag-1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handleNewQuestionNotification() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case task := <-service.newQuestionEmailWorker.tasks:
|
||||
t.Fatalf("unexpected enqueued task: %+v", task)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewQuestionNotificationReturnsWhenEmailWorkerQueueFull(t *testing.T) {
|
||||
cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{})
|
||||
if err != nil {
|
||||
t.Fatalf("new cache: %v", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
worker := newUnstartedNewQuestionEmailWorkerForTest()
|
||||
if !worker.TryEnqueue(newQuestionEmailWorkerTask("already-queued", "queued-user")) {
|
||||
t.Fatalf("pre-fill TryEnqueue() = false, want true")
|
||||
}
|
||||
service := &ExternalNotificationService{
|
||||
data: &basedata.Data{Cache: cache},
|
||||
userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{
|
||||
allQuestionConfigs: []*entity.UserNotificationConfig{
|
||||
newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, true),
|
||||
},
|
||||
},
|
||||
followRepo: &newQuestionNotificationTestFollowRepo{
|
||||
followersByObjectID: map[string][]string{},
|
||||
},
|
||||
userRepo: &newQuestionNotificationTestUserRepo{
|
||||
users: map[string]*entity.User{
|
||||
"all-user": newQuestionNotificationTestUser("all-user"),
|
||||
},
|
||||
},
|
||||
newQuestionEmailWorker: worker,
|
||||
}
|
||||
|
||||
err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{
|
||||
NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{
|
||||
QuestionTitle: "New question",
|
||||
QuestionID: "1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handleNewQuestionNotification() error = %v", err)
|
||||
}
|
||||
if got := len(worker.tasks); got != 1 {
|
||||
t.Fatalf("worker queue length = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNewQuestionNotificationSyncsPluginBeforeEmailEnqueue(t *testing.T) {
|
||||
cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{})
|
||||
if err != nil {
|
||||
t.Fatalf("new cache: %v", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
siteInfoService := mock.NewMockSiteInfoCommonService(ctrl)
|
||||
siteInfoService.EXPECT().GetSiteGeneral(gomock.Any()).Return(&schema.SiteGeneralResp{
|
||||
Name: "Answer",
|
||||
SiteUrl: "https://answer.test",
|
||||
ContactEmail: "support@answer.test",
|
||||
}, nil).AnyTimes()
|
||||
siteInfoService.EXPECT().GetSiteSeo(gomock.Any()).Return(&schema.SiteSeoResp{
|
||||
Permalink: constant.PermalinkQuestionIDAndTitle,
|
||||
}, nil).AnyTimes()
|
||||
siteInfoService.EXPECT().GetSiteInterface(gomock.Any()).Return(&schema.SiteInterfaceSettingsResp{
|
||||
Language: "en",
|
||||
}, nil).AnyTimes()
|
||||
|
||||
notifyStarted := make(chan plugin.NotificationMessage, 1)
|
||||
releaseNotify := make(chan struct{})
|
||||
enableNewQuestionNotificationTestPlugin(t, notifyStarted, releaseNotify)
|
||||
|
||||
worker := newUnstartedNewQuestionEmailWorkerForTest()
|
||||
service := &ExternalNotificationService{
|
||||
data: &basedata.Data{Cache: cache},
|
||||
userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{
|
||||
followedTagConfigs: map[string]*entity.UserNotificationConfig{
|
||||
"tag-user": newQuestionNotificationConfig(
|
||||
"tag-user", constant.AllNewQuestionForFollowingTagsSource, true),
|
||||
},
|
||||
},
|
||||
followRepo: &newQuestionNotificationTestFollowRepo{
|
||||
followersByObjectID: map[string][]string{"tag-1": {"tag-user"}},
|
||||
},
|
||||
userRepo: &newQuestionNotificationTestUserRepo{
|
||||
users: map[string]*entity.User{
|
||||
"tag-user": newQuestionNotificationTestUser("tag-user"),
|
||||
},
|
||||
},
|
||||
userExternalLoginRepo: newQuestionNotificationTestUserExternalLoginRepo{},
|
||||
siteInfoService: siteInfoService,
|
||||
newQuestionEmailWorker: worker,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{
|
||||
NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{
|
||||
QuestionTitle: "New question",
|
||||
QuestionID: "1",
|
||||
Tags: []string{"go"},
|
||||
TagIDs: []string{"tag-1"},
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-notifyStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("plugin notification was not sent")
|
||||
}
|
||||
select {
|
||||
case task := <-worker.tasks:
|
||||
t.Fatalf("email task enqueued before plugin sync completed: %+v", task)
|
||||
default:
|
||||
}
|
||||
close(releaseNotify)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("handleNewQuestionNotification() error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("handleNewQuestionNotification() did not return")
|
||||
}
|
||||
select {
|
||||
case task := <-worker.tasks:
|
||||
assertStringSet(t, task.UserIDs, []string{"tag-user"})
|
||||
default:
|
||||
t.Fatalf("expected email task after plugin sync completed")
|
||||
}
|
||||
}
|
||||
|
||||
func assertUniqueNewQuestionUnsubscribeCodes(t *testing.T, codes []string) {
|
||||
t.Helper()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, code := range codes {
|
||||
if seen[code] {
|
||||
t.Fatalf("duplicate unsubscribe code %q", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
func setNewQuestionNotificationEmailSendIntervalEnv(t *testing.T, value string, set bool) {
|
||||
t.Helper()
|
||||
|
||||
oldValue, oldSet := os.LookupEnv(newQuestionNotificationEmailSendIntervalEnv)
|
||||
if set {
|
||||
if err := os.Setenv(newQuestionNotificationEmailSendIntervalEnv, value); err != nil {
|
||||
t.Fatalf("set env: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Unsetenv(newQuestionNotificationEmailSendIntervalEnv); err != nil {
|
||||
t.Fatalf("unset env: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if oldSet {
|
||||
_ = os.Setenv(newQuestionNotificationEmailSendIntervalEnv, oldValue)
|
||||
} else {
|
||||
_ = os.Unsetenv(newQuestionNotificationEmailSendIntervalEnv)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newQuestionEmailChannel(enable bool) *schema.NotificationChannelConfig {
|
||||
return &schema.NotificationChannelConfig{
|
||||
Key: constant.EmailChannel,
|
||||
Enable: enable,
|
||||
}
|
||||
}
|
||||
|
||||
func newQuestionNotificationConfig(
|
||||
userID string, source constant.NotificationSource, emailEnabled bool) *entity.UserNotificationConfig {
|
||||
channels := schema.NotificationChannels{
|
||||
newQuestionEmailChannel(emailEnabled),
|
||||
}
|
||||
return &entity.UserNotificationConfig{
|
||||
UserID: userID,
|
||||
Source: string(source),
|
||||
Channels: channels.ToJsonString(),
|
||||
Enabled: emailEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func newQuestionNotificationTestUser(userID string) *entity.User {
|
||||
return &entity.User{
|
||||
ID: userID,
|
||||
Username: userID,
|
||||
DisplayName: userID,
|
||||
EMail: userID + "@example.com",
|
||||
Status: entity.UserStatusAvailable,
|
||||
MailStatus: entity.EmailStatusAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSet(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
|
||||
gotSet := make(map[string]bool)
|
||||
for _, value := range got {
|
||||
gotSet[value] = true
|
||||
}
|
||||
wantSet := make(map[string]bool)
|
||||
for _, value := range want {
|
||||
wantSet[value] = true
|
||||
}
|
||||
if !reflect.DeepEqual(gotSet, wantSet) {
|
||||
t.Fatalf("values = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type newQuestionNotificationTestFollowRepo struct {
|
||||
followersByObjectID map[string][]string
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestFollowRepo) GetFollowIDs(
|
||||
context.Context, string, string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestFollowRepo) GetFollowAmount(context.Context, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestFollowRepo) GetFollowUserIDs(
|
||||
_ context.Context, objectID string) ([]string, error) {
|
||||
return r.followersByObjectID[objectID], nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestFollowRepo) IsFollowed(context.Context, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestFollowRepo) MigrateFollowers(
|
||||
context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type newQuestionNotificationTestUserNotificationConfigRepo struct {
|
||||
followedTagConfigs map[string]*entity.UserNotificationConfig
|
||||
allQuestionConfigs []*entity.UserNotificationConfig
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserNotificationConfigRepo) Add(
|
||||
context.Context, []string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserNotificationConfigRepo) Save(
|
||||
context.Context, *entity.UserNotificationConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUserID(
|
||||
context.Context, string) ([]*entity.UserNotificationConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetBySource(
|
||||
_ context.Context, source constant.NotificationSource) ([]*entity.UserNotificationConfig, error) {
|
||||
if source == constant.AllNewQuestionSource {
|
||||
return r.allQuestionConfigs, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUserIDAndSource(
|
||||
context.Context, string, constant.NotificationSource) (*entity.UserNotificationConfig, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUsersAndSource(
|
||||
_ context.Context, userIDs []string, source constant.NotificationSource) (
|
||||
[]*entity.UserNotificationConfig, error) {
|
||||
if source != constant.AllNewQuestionForFollowingTagsSource {
|
||||
return nil, nil
|
||||
}
|
||||
configs := make([]*entity.UserNotificationConfig, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if config, ok := r.followedTagConfigs[userID]; ok {
|
||||
configs = append(configs, config)
|
||||
}
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
type newQuestionNotificationTestUserRepo struct {
|
||||
users map[string]*entity.User
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) AddUser(context.Context, *entity.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) IncreaseAnswerCount(context.Context, string, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) IncreaseQuestionCount(context.Context, string, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateQuestionCount(context.Context, string, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateAnswerCount(context.Context, string, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateLastLoginDate(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateEmailStatus(context.Context, string, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateNoticeStatus(context.Context, string, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateEmail(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateUserInterface(
|
||||
context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdatePass(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateInfo(context.Context, *entity.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) UpdateUserProfile(context.Context, *entity.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) GetByUserID(
|
||||
_ context.Context, userID string) (*entity.User, bool, error) {
|
||||
user, ok := r.users[userID]
|
||||
return user, ok, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) BatchGetByID(
|
||||
context.Context, []string) ([]*entity.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) GetByUsername(
|
||||
context.Context, string) (*entity.User, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) GetByUsernames(
|
||||
context.Context, []string) ([]*entity.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) GetByEmail(
|
||||
context.Context, string) (*entity.User, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) GetUserCount(context.Context) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) SearchUserListByName(
|
||||
context.Context, string, int, bool) ([]*entity.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestUserRepo) IsAvatarFileUsed(context.Context, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type newQuestionNotificationTestConfigRepo struct{}
|
||||
|
||||
func (newQuestionNotificationTestConfigRepo) GetConfigByID(
|
||||
context.Context, int) (*entity.Config, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestConfigRepo) GetConfigByKey(
|
||||
context.Context, string) (*entity.Config, error) {
|
||||
config := export.EmailConfig{
|
||||
FromEmail: "noreply@answer.test",
|
||||
FromName: "Answer",
|
||||
}
|
||||
value, _ := json.Marshal(config)
|
||||
return &entity.Config{
|
||||
Value: string(value),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestConfigRepo) GetConfigByKeyFromDB(
|
||||
context.Context, string) (*entity.Config, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestConfigRepo) UpdateConfig(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type newQuestionNotificationTestEmailRepo struct {
|
||||
codesByUserID map[string][]string
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestEmailRepo) SetCode(
|
||||
_ context.Context, userID, code, _ string, _ time.Duration) error {
|
||||
r.codesByUserID[userID] = append(r.codesByUserID[userID], code)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *newQuestionNotificationTestEmailRepo) VerifyCode(context.Context, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var (
|
||||
newQuestionNotificationTestPluginOnce sync.Once
|
||||
newQuestionNotificationTestPluginInst = &newQuestionNotificationTestPlugin{}
|
||||
)
|
||||
|
||||
func enableNewQuestionNotificationTestPlugin(
|
||||
t *testing.T,
|
||||
notifyStarted chan plugin.NotificationMessage,
|
||||
releaseNotify <-chan struct{},
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
newQuestionNotificationTestPluginInst.setChannels(notifyStarted, releaseNotify)
|
||||
newQuestionNotificationTestPluginOnce.Do(func() {
|
||||
plugin.Register(newQuestionNotificationTestPluginInst)
|
||||
})
|
||||
plugin.StatusManager.Enable(newQuestionNotificationTestPluginInst.Info().SlugName, true)
|
||||
t.Cleanup(func() {
|
||||
plugin.StatusManager.Enable(newQuestionNotificationTestPluginInst.Info().SlugName, false)
|
||||
newQuestionNotificationTestPluginInst.setChannels(nil, nil)
|
||||
})
|
||||
}
|
||||
|
||||
type newQuestionNotificationTestPlugin struct {
|
||||
mu sync.Mutex
|
||||
notifyStarted chan plugin.NotificationMessage
|
||||
releaseNotify <-chan struct{}
|
||||
}
|
||||
|
||||
func (p *newQuestionNotificationTestPlugin) Info() plugin.Info {
|
||||
return plugin.Info{SlugName: "new-question-notification-test-plugin"}
|
||||
}
|
||||
|
||||
func (p *newQuestionNotificationTestPlugin) GetNewQuestionSubscribers() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *newQuestionNotificationTestPlugin) Notify(msg plugin.NotificationMessage) {
|
||||
p.mu.Lock()
|
||||
notifyStarted := p.notifyStarted
|
||||
releaseNotify := p.releaseNotify
|
||||
p.mu.Unlock()
|
||||
|
||||
if notifyStarted != nil {
|
||||
select {
|
||||
case notifyStarted <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if releaseNotify != nil {
|
||||
<-releaseNotify
|
||||
}
|
||||
}
|
||||
|
||||
func (p *newQuestionNotificationTestPlugin) setChannels(
|
||||
notifyStarted chan plugin.NotificationMessage,
|
||||
releaseNotify <-chan struct{},
|
||||
) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.notifyStarted = notifyStarted
|
||||
p.releaseNotify = releaseNotify
|
||||
}
|
||||
|
||||
type newQuestionNotificationTestUserExternalLoginRepo struct{}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) AddUserExternalLogin(
|
||||
context.Context, *entity.UserExternalLogin) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) UpdateInfo(
|
||||
context.Context, *entity.UserExternalLogin) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) GetByExternalID(
|
||||
context.Context, string, string) (*entity.UserExternalLogin, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) GetByUserID(
|
||||
context.Context, string, string) (*entity.UserExternalLogin, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) GetUserExternalLoginList(
|
||||
context.Context, string) ([]*entity.UserExternalLogin, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) DeleteUserExternalLogin(
|
||||
context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) DeleteUserExternalLoginByUserID(
|
||||
context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) SetCacheUserExternalLoginInfo(
|
||||
context.Context, string, *schema.ExternalLoginUserInfoCache) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheUserExternalLoginInfo(
|
||||
context.Context, string) (*schema.ExternalLoginUserInfoCache, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) SetCacheOAuthState(
|
||||
context.Context, string, *schema.ExternalLoginOAuthState, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheOAuthState(
|
||||
context.Context, string) (*schema.ExternalLoginOAuthState, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (newQuestionNotificationTestUserExternalLoginRepo) DeleteCacheOAuthState(
|
||||
context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canMe
|
||||
})
|
||||
}
|
||||
|
||||
if canRecover && status == entity.QuestionStatusDeleted {
|
||||
if canRecover && status == entity.TagStatusDeleted {
|
||||
actions = append(actions, &schema.PermissionMemberAction{
|
||||
Action: "undelete",
|
||||
Name: translator.Tr(lang, undeleteActionName),
|
||||
|
||||
@@ -88,7 +88,7 @@ type QuestionRepo interface {
|
||||
RemoveQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
|
||||
RecoverQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
|
||||
UpdateQuestionLinkStatus(ctx context.Context, status int, links ...*entity.QuestionLink) (err error)
|
||||
GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
|
||||
GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
|
||||
}
|
||||
|
||||
// QuestionCommon user service
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/apache/answer/internal/service/eventqueue"
|
||||
@@ -44,7 +45,6 @@ import (
|
||||
"github.com/jinzhu/copier"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// ReportService user service
|
||||
|
||||
@@ -291,7 +291,18 @@ func (s *SiteInfoService) SaveSiteSecurity(ctx context.Context, req *schema.Site
|
||||
|
||||
// SaveSiteLogin save site legal configuration
|
||||
func (s *SiteInfoService) SaveSiteLogin(ctx context.Context, req *schema.SiteLoginReq) (err error) {
|
||||
content, _ := json.Marshal(req)
|
||||
if req.RequireEmailVerification == nil {
|
||||
return errors.BadRequest(reason.RequestFormatError)
|
||||
}
|
||||
|
||||
loginConfig := &schema.SiteLoginResp{
|
||||
AllowNewRegistrations: req.AllowNewRegistrations,
|
||||
AllowEmailRegistrations: req.AllowEmailRegistrations,
|
||||
AllowPasswordLogin: req.AllowPasswordLogin,
|
||||
AllowEmailDomains: req.AllowEmailDomains,
|
||||
RequireEmailVerification: *req.RequireEmailVerification,
|
||||
}
|
||||
content, _ := json.Marshal(loginConfig)
|
||||
data := &entity.SiteInfo{
|
||||
Type: constant.SiteTypeLogin,
|
||||
Content: string(content),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package siteinfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/apache/answer/internal/service/mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestSiteInfoService_SaveSiteLoginRequireEmailVerification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requireEmail bool
|
||||
expectedRequire bool
|
||||
}{
|
||||
{
|
||||
name: "explicit true persists true",
|
||||
requireEmail: true,
|
||||
expectedRequire: true,
|
||||
},
|
||||
{
|
||||
name: "explicit false persists false",
|
||||
requireEmail: false,
|
||||
expectedRequire: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
defer ctl.Finish()
|
||||
|
||||
repo := mock.NewMockSiteInfoRepo(ctl)
|
||||
var savedContent string
|
||||
repo.EXPECT().SaveByType(gomock.Any(), constant.SiteTypeLogin, gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, _ string, data *entity.SiteInfo) error {
|
||||
savedContent = data.Content
|
||||
return nil
|
||||
})
|
||||
|
||||
service := &SiteInfoService{
|
||||
siteInfoRepo: repo,
|
||||
}
|
||||
req := &schema.SiteLoginReq{
|
||||
AllowNewRegistrations: true,
|
||||
AllowEmailRegistrations: true,
|
||||
AllowPasswordLogin: true,
|
||||
AllowEmailDomains: []string{},
|
||||
RequireEmailVerification: &tt.requireEmail,
|
||||
}
|
||||
require.NoError(t, service.SaveSiteLogin(context.TODO(), req))
|
||||
assert.NotContains(t, savedContent, `"require_email_verification":null`)
|
||||
|
||||
saved := &schema.SiteLoginResp{}
|
||||
require.NoError(t, json.Unmarshal([]byte(savedContent), saved))
|
||||
assert.Equal(t, tt.expectedRequire, saved.RequireEmailVerification)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteInfoService_SaveSiteLoginRequiresEmailVerificationValue(t *testing.T) {
|
||||
service := &SiteInfoService{}
|
||||
req := &schema.SiteLoginReq{
|
||||
AllowNewRegistrations: true,
|
||||
AllowEmailRegistrations: true,
|
||||
AllowPasswordLogin: true,
|
||||
AllowEmailDomains: []string{},
|
||||
}
|
||||
|
||||
require.Error(t, service.SaveSiteLogin(context.TODO(), req))
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func (s *siteInfoCommonService) GetSiteSecurity(ctx context.Context) (resp *sche
|
||||
|
||||
// GetSiteLogin get site login config
|
||||
func (s *siteInfoCommonService) GetSiteLogin(ctx context.Context) (resp *schema.SiteLoginResp, err error) {
|
||||
resp = &schema.SiteLoginResp{}
|
||||
resp = &schema.SiteLoginResp{RequireEmailVerification: true}
|
||||
if err = s.GetSiteInfoByType(ctx, constant.SiteTypeLogin, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -50,3 +50,47 @@ func TestSiteInfoCommonService_GetSiteGeneral(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "name", resp.Name)
|
||||
}
|
||||
|
||||
func TestSiteInfoCommonService_GetSiteLoginRequireEmailVerification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "missing key defaults true",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "null defaults true",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":null}`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "explicit false is preserved",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "explicit true is preserved",
|
||||
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":true}`,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
defer ctl.Finish()
|
||||
repo := mock.NewMockSiteInfoRepo(ctl)
|
||||
repo.EXPECT().GetByType(gomock.Any(), constant.SiteTypeLogin).
|
||||
Return(&entity.SiteInfo{Content: tt.content}, true, nil)
|
||||
|
||||
siteInfoCommonService := NewSiteInfoCommonService(repo)
|
||||
resp, err := siteInfoCommonService.GetSiteLogin(context.TODO())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, resp.RequireEmailVerification)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +319,13 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
|
||||
if err := ctx.SaveUploadedFile(file, filePath); err != nil {
|
||||
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
|
||||
}
|
||||
saved := false
|
||||
defer func() {
|
||||
_ = src.Close()
|
||||
if !saved {
|
||||
if removeErr := os.Remove(filePath); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
log.Errorf("remove failed uploaded file failed: %v", removeErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if !checker.DecodeAndCheckImageFile(filePath, siteAdvanced.GetMaxImageMegapixel()) {
|
||||
@@ -337,6 +337,7 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
|
||||
}
|
||||
|
||||
url = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, fileSubPath)
|
||||
saved = true
|
||||
return url, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/apache/answer/internal/service/activity"
|
||||
"github.com/apache/answer/internal/service/apikey"
|
||||
"github.com/apache/answer/internal/service/auth"
|
||||
"github.com/apache/answer/internal/service/role"
|
||||
"github.com/apache/answer/internal/service/siteinfo_common"
|
||||
@@ -87,6 +88,7 @@ type UserAdminService struct {
|
||||
notificationRepo notificationcommon.NotificationRepo
|
||||
pluginUserConfigRepo plugin_common.PluginUserConfigRepo
|
||||
badgeAwardRepo badge.BadgeAwardRepo
|
||||
apiKeyRepo apikey.APIKeyRepo
|
||||
}
|
||||
|
||||
// NewUserAdminService new user admin service
|
||||
@@ -105,6 +107,7 @@ func NewUserAdminService(
|
||||
notificationRepo notificationcommon.NotificationRepo,
|
||||
pluginUserConfigRepo plugin_common.PluginUserConfigRepo,
|
||||
badgeAwardRepo badge.BadgeAwardRepo,
|
||||
apiKeyRepo apikey.APIKeyRepo,
|
||||
) *UserAdminService {
|
||||
return &UserAdminService{
|
||||
userRepo: userRepo,
|
||||
@@ -121,6 +124,7 @@ func NewUserAdminService(
|
||||
notificationRepo: notificationRepo,
|
||||
pluginUserConfigRepo: pluginUserConfigRepo,
|
||||
badgeAwardRepo: badgeAwardRepo,
|
||||
apiKeyRepo: apiKeyRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +166,11 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.IsInactive() || req.IsSuspended() || req.IsDeleted() {
|
||||
if err := us.revokeUserAPIKeys(ctx, userInfo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// remove all content that user created, such as question, answer, comment, etc.
|
||||
if req.RemoveAllContent {
|
||||
@@ -227,11 +236,18 @@ func (us *UserAdminService) UpdateUserRole(ctx context.Context, req *schema.Upda
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := us.revokeUserAPIKeys(ctx, req.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
us.authService.RemoveUserAllTokens(ctx, req.UserID)
|
||||
return
|
||||
}
|
||||
|
||||
func (us *UserAdminService) revokeUserAPIKeys(ctx context.Context, userID string) error {
|
||||
return us.apiKeyRepo.DeleteAPIKeysByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// AddUser add user
|
||||
func (us *UserAdminService) AddUser(ctx context.Context, req *schema.AddUserReq) (err error) {
|
||||
_, has, err := us.userRepo.GetUserInfoByEmail(ctx, req.Email)
|
||||
@@ -581,8 +597,9 @@ func (us *UserAdminService) GetUserActivation(ctx context.Context, req *schema.G
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
us.emailService.SaveCode(ctx, userInfo.ID, code, data.ToJSONString())
|
||||
@@ -608,8 +625,9 @@ func (us *UserAdminService) SendUserActivation(ctx context.Context, req *schema.
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", general.SiteUrl, code)
|
||||
|
||||
@@ -54,6 +54,10 @@ type UserExternalLoginRepo interface {
|
||||
DeleteUserExternalLoginByUserID(ctx context.Context, userID string) (err error)
|
||||
SetCacheUserExternalLoginInfo(ctx context.Context, key string, info *schema.ExternalLoginUserInfoCache) (err error)
|
||||
GetCacheUserExternalLoginInfo(ctx context.Context, key string) (info *schema.ExternalLoginUserInfoCache, err error)
|
||||
SetCacheOAuthState(ctx context.Context, state string, info *schema.ExternalLoginOAuthState,
|
||||
duration time.Duration) (err error)
|
||||
GetCacheOAuthState(ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error)
|
||||
DeleteCacheOAuthState(ctx context.Context, state string) (err error)
|
||||
}
|
||||
|
||||
// UserExternalLoginService user external login service
|
||||
@@ -88,6 +92,41 @@ func NewUserExternalLoginService(
|
||||
}
|
||||
}
|
||||
|
||||
func (us *UserExternalLoginService) GenerateOAuthState(
|
||||
ctx context.Context, provider, intent, userID string) (state string, err error) {
|
||||
state = token.GenerateToken()
|
||||
duration := constant.ConnectorOAuthStateCacheTime
|
||||
if intent == schema.ExternalLoginOAuthStateBindIntent {
|
||||
duration = constant.ConnectorOAuthBindStateCacheTime
|
||||
}
|
||||
err = us.userExternalLoginRepo.SetCacheOAuthState(ctx, state, &schema.ExternalLoginOAuthState{
|
||||
Provider: provider,
|
||||
Intent: intent,
|
||||
UserID: userID,
|
||||
}, duration)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (us *UserExternalLoginService) GetOAuthState(
|
||||
ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error) {
|
||||
if len(state) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return us.userExternalLoginRepo.GetCacheOAuthState(ctx, state)
|
||||
}
|
||||
|
||||
func (us *UserExternalLoginService) ConsumeOAuthState(
|
||||
ctx context.Context, state string) (info *schema.ExternalLoginOAuthState, err error) {
|
||||
info, err = us.GetOAuthState(ctx, state)
|
||||
if err != nil || info == nil {
|
||||
return info, err
|
||||
}
|
||||
if err = us.userExternalLoginRepo.DeleteCacheOAuthState(ctx, state); err != nil {
|
||||
log.Errorf("delete oauth state failed: %v", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ExternalLogin if user is already a member logged in
|
||||
func (us *UserExternalLoginService) ExternalLogin(
|
||||
ctx context.Context, externalUserInfo *schema.ExternalLoginUserInfoCache) (
|
||||
@@ -147,16 +186,18 @@ func (us *UserExternalLoginService) ExternalLogin(
|
||||
}, nil
|
||||
}
|
||||
|
||||
oldUserInfo, exist, err := us.userRepo.GetByEmail(ctx, externalUserInfo.Email)
|
||||
if err != nil {
|
||||
if _, exist, err := us.userRepo.GetByEmail(ctx, externalUserInfo.Email); err != nil {
|
||||
return nil, err
|
||||
} else if exist {
|
||||
return &schema.UserExternalLoginResp{
|
||||
ErrTitle: translator.Tr(handler.GetLangByCtx(ctx), reason.UserAccessDenied),
|
||||
ErrMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.UserAccessDenied),
|
||||
}, nil
|
||||
}
|
||||
// if user is not a member, register a new user
|
||||
if !exist {
|
||||
oldUserInfo, err = us.registerNewUser(ctx, externalUserInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldUserInfo, err := us.registerNewUser(ctx, externalUserInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// bind external user info to user
|
||||
err = us.bindOldUser(ctx, externalUserInfo, oldUserInfo)
|
||||
@@ -176,10 +217,41 @@ func (us *UserExternalLoginService) ExternalLogin(
|
||||
}
|
||||
|
||||
accessToken, _, err := us.userCommonService.CacheLoginUserInfo(
|
||||
ctx, oldUserInfo.ID, newMailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID)
|
||||
ctx, oldUserInfo.ID, newMailStatus, oldUserInfo.Status, externalUserInfo.ExternalID)
|
||||
return &schema.UserExternalLoginResp{AccessToken: accessToken}, err
|
||||
}
|
||||
|
||||
func (us *UserExternalLoginService) BindExternalLoginToUser(ctx context.Context,
|
||||
userID string, externalUserInfo *schema.ExternalLoginUserInfoCache) error {
|
||||
if len(userID) == 0 || len(externalUserInfo.ExternalID) == 0 {
|
||||
return errors.BadRequest(reason.UserAccessDenied)
|
||||
}
|
||||
oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exist || oldUserInfo.Status == entity.UserStatusDeleted {
|
||||
return errors.BadRequest(reason.UserNotFound)
|
||||
}
|
||||
oldExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByExternalID(ctx,
|
||||
externalUserInfo.Provider, externalUserInfo.ExternalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist && oldExternalLoginUserInfo.UserID != userID {
|
||||
return errors.BadRequest(reason.UserAccessDenied)
|
||||
}
|
||||
currentExternalLoginUserInfo, exist, err := us.userExternalLoginRepo.GetByUserID(ctx,
|
||||
externalUserInfo.Provider, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist && currentExternalLoginUserInfo.ExternalID != externalUserInfo.ExternalID {
|
||||
return errors.BadRequest(reason.UserAccessDenied)
|
||||
}
|
||||
return us.bindOldUser(ctx, externalUserInfo, oldUserInfo)
|
||||
}
|
||||
|
||||
func (us *UserExternalLoginService) registerNewUser(ctx context.Context,
|
||||
externalUserInfo *schema.ExternalLoginUserInfoCache) (userInfo *entity.User, err error) {
|
||||
userInfo = &entity.User{}
|
||||
@@ -289,26 +361,25 @@ func (us *UserExternalLoginService) ExternalLoginBindingUserSendEmail(
|
||||
return &schema.ExternalLoginBindingUserSendEmailResp{}, nil
|
||||
}
|
||||
|
||||
userInfo, exist, err := us.userRepo.GetByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if _, exist, err := us.userRepo.GetByEmail(ctx, req.Email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist && !req.Must {
|
||||
} else if exist && !req.Must {
|
||||
resp.EmailExistAndMustBeConfirmed = true
|
||||
return resp, nil
|
||||
} else if exist {
|
||||
resp.EmailExistAndMustBeConfirmed = true
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if !exist {
|
||||
externalLoginInfo.Email = req.Email
|
||||
userInfo, err = us.registerNewUser(ctx, externalLoginInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.AccessToken, _, err = us.userCommonService.CacheLoginUserInfo(
|
||||
ctx, userInfo.ID, userInfo.MailStatus, userInfo.Status, externalLoginInfo.ExternalID)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
externalLoginInfo.Email = req.Email
|
||||
userInfo, err := us.registerNewUser(ctx, externalLoginInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.AccessToken, _, err = us.userCommonService.CacheLoginUserInfo(
|
||||
ctx, userInfo.ID, userInfo.MailStatus, userInfo.Status, externalLoginInfo.ExternalID)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
err = us.userExternalLoginRepo.SetCacheUserExternalLoginInfo(ctx, req.BindingKey, externalLoginInfo)
|
||||
if err != nil {
|
||||
@@ -340,6 +411,9 @@ func (us *UserExternalLoginService) ExternalLoginBindingUser(
|
||||
if err != nil || externalLoginInfo == nil {
|
||||
return errors.BadRequest(reason.UserNotFound)
|
||||
}
|
||||
if len(externalLoginInfo.Email) == 0 || externalLoginInfo.Email != oldUserInfo.EMail {
|
||||
return errors.BadRequest(reason.UserAccessDenied)
|
||||
}
|
||||
return us.bindOldUser(ctx, externalLoginInfo, oldUserInfo)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ func DecodeAndCheckImageFile(localFilePath string, maxImageMegapixel int) bool {
|
||||
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, webpImageCheck) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeAndCheckImageFileRejectsUnsupportedExtension(t *testing.T) {
|
||||
filePath := filepath.Join(t.TempDir(), "not-an-image.svg")
|
||||
if err := os.WriteFile(filePath, []byte("<svg></svg>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if DecodeAndCheckImageFile(filePath, 1_000_000) {
|
||||
t.Fatal("unsupported image extensions must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderLinkIsUrl(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"absolute http URL", "http://example.com/path?q=1#f", true},
|
||||
{"absolute https URL", "https://example.com", true},
|
||||
{"ftp URL", "ftp://example.com/file", true},
|
||||
{"uppercase scheme and host", "HTTP://EXAMPLE.COM", false},
|
||||
{"bare domain", "example.com", true},
|
||||
{"bare domain with path", "example.com/questions/123", true},
|
||||
{"www subdomain", "www.example.com", true},
|
||||
{"bare IP", "10.0.0.1", true},
|
||||
{"IP with port and path", "10.0.0.1:8080/a", true},
|
||||
{"host with port", "localhost:8080", true},
|
||||
{"domain with port and path", "example.com:8080/x", true},
|
||||
{"IPv6 with port", "[::1]:8080", true},
|
||||
{"userinfo", "user:pass@example.com", true},
|
||||
{"mailto", "mailto:a@b.com", true},
|
||||
{"email-like destination", "a@b.co", true},
|
||||
{"userinfo without scheme", "user@h.co", true},
|
||||
{"trailing dot FQDN", "example.com.", true},
|
||||
{"empty", "", false},
|
||||
{"single word", "foo", false},
|
||||
{"path segment no dot", "questions/123", false},
|
||||
{"absolute path", "/questions/123", true},
|
||||
{"scheme-less authority path", "//cdn.example.com/x", true},
|
||||
{"anchor", "#section", false},
|
||||
{"leading dot", ".hidden", false},
|
||||
{"javascript scheme", "javascript:alert(1)", false},
|
||||
{"tel scheme", "tel:+1234", false},
|
||||
{"host with leading dot", "http://.example.com", false},
|
||||
{"trailing colon", "example.com:", false},
|
||||
{"single label with scheme", "http://localhost", true},
|
||||
{"single label no scheme no port", "localhost", false},
|
||||
{"not a url", "not a url", false},
|
||||
{"whitespace in path", "h.co/p q", false},
|
||||
{"label with leading hyphen", "-ex.com", false},
|
||||
{"label with trailing hyphen", "ex-.com", false},
|
||||
{"invalid IPv4 quad", "999.1.1.1", false},
|
||||
{"IPv4 with leading zeros", "01.2.3.4", false},
|
||||
{"three letter domain", "a.b", false},
|
||||
}
|
||||
r := &DangerousHTMLRenderer{}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, r.renderLinkIsUrl(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Machiel/slugify"
|
||||
@@ -32,6 +34,7 @@ import (
|
||||
"github.com/apache/answer/pkg/converter"
|
||||
strip "github.com/grokify/html-strip-tags-go"
|
||||
"github.com/mozillazg/go-pinyin"
|
||||
"github.com/mozillazg/go-unidecode"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -47,8 +50,27 @@ var (
|
||||
"\r", " ",
|
||||
"\t", " ",
|
||||
)
|
||||
|
||||
// Without this, pure non-Latin titles (Arabic, Cyrillic, Hebrew, ...) get
|
||||
// stripped by slugify and collapse to the "topic" fallback. Chinese is
|
||||
// handled separately by convertChinese.
|
||||
transliterateNonLatin atomic.Bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
transliterateNonLatin.Store(true)
|
||||
}
|
||||
|
||||
// SetTransliterateNonLatin toggles non-Latin script transliteration for URL slugs.
|
||||
func SetTransliterateNonLatin(enabled bool) {
|
||||
transliterateNonLatin.Store(enabled)
|
||||
}
|
||||
|
||||
// IsTransliterateNonLatinEnabled reports whether non-Latin transliteration is on.
|
||||
func IsTransliterateNonLatinEnabled() bool {
|
||||
return transliterateNonLatin.Load()
|
||||
}
|
||||
|
||||
// ClearText clear HTML, get the clear text
|
||||
func ClearText(html string) string {
|
||||
if html == "" {
|
||||
@@ -66,6 +88,9 @@ func ClearText(html string) string {
|
||||
|
||||
func UrlTitle(title string) (text string) {
|
||||
title = convertChinese(title)
|
||||
if transliterateNonLatin.Load() {
|
||||
title = convertNonLatin(title)
|
||||
}
|
||||
title = clearEmoji(title)
|
||||
title = slugify.Slugify(title)
|
||||
title = url.QueryEscape(title)
|
||||
@@ -95,6 +120,30 @@ func convertChinese(content string) string {
|
||||
return strings.Join(pinyin.LazyConvert(content, nil), "-")
|
||||
}
|
||||
|
||||
// Short-circuits on Latin-only / Chinese-only input so existing slugs stay byte-identical.
|
||||
func convertNonLatin(content string) string {
|
||||
if !containsNonLatin(content) {
|
||||
return content
|
||||
}
|
||||
return unidecode.Unidecode(content)
|
||||
}
|
||||
|
||||
func containsNonLatin(content string) bool {
|
||||
for _, r := range content {
|
||||
switch {
|
||||
case r < 0x0080: // ASCII
|
||||
continue
|
||||
case r >= 0x0080 && r <= 0x024F: // Latin-1 Supplement, Latin Extended-A/B
|
||||
continue
|
||||
case unicode.Is(unicode.Han, r): // handled by convertChinese
|
||||
continue
|
||||
case unicode.IsLetter(r):
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cutLongTitle(title string) string {
|
||||
maxBytes := 150
|
||||
if len(title) <= maxBytes {
|
||||
|
||||
@@ -87,6 +87,111 @@ func TestUrlTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUrlTitleTable(t *testing.T) {
|
||||
// Long pure-Arabic title: 50 copies of the same Arabic word, joined by spaces.
|
||||
// Unidecode of "كيف" is "kyf", so the slug becomes "kyf-" repeated and
|
||||
// exceeds cutLongTitle's 150-byte cap.
|
||||
longArabic := strings.Repeat("كيف ", 50)
|
||||
wantLongArabic := strings.Repeat("kyf-", 37) + "ky" // 37*4 + 2 = 150 bytes
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
title string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
title: "",
|
||||
want: "topic",
|
||||
},
|
||||
{
|
||||
name: "pure latin unchanged",
|
||||
title: "hello world",
|
||||
want: "hello-world",
|
||||
},
|
||||
{
|
||||
// Pinyin conversion drops Latin runes by design — matches pre-fix behavior.
|
||||
name: "pure chinese unchanged",
|
||||
title: "这是一个,标题,title",
|
||||
want: "zhe-shi-yi-ge-biao-ti",
|
||||
},
|
||||
{
|
||||
// The fix: previously collapsed to "topic" for all of these scripts.
|
||||
// Outputs are an ASCII approximation, not linguistically correct
|
||||
// romanization — see PR description.
|
||||
name: "arabic transliterated",
|
||||
title: "كيف حالك",
|
||||
want: "kyf-hlk",
|
||||
},
|
||||
{
|
||||
name: "mixed latin and arabic",
|
||||
title: "مرحبا hello",
|
||||
want: "mrhb-hello",
|
||||
},
|
||||
{
|
||||
name: "thai transliterated",
|
||||
title: "ไทย ไทย",
|
||||
want: "aithy-aithy",
|
||||
},
|
||||
{
|
||||
name: "japanese hiragana transliterated",
|
||||
title: "こんにちは",
|
||||
want: "konnichiha",
|
||||
},
|
||||
{
|
||||
// Japanese with Han-block kanji is caught by the pre-existing pinyin
|
||||
// pre-step (Chinese reading, not Japanese), so this path is unchanged
|
||||
// by this PR. Pinning to document the existing behavior.
|
||||
name: "japanese kanji goes through pinyin path unchanged",
|
||||
title: "日本",
|
||||
want: "ri-ben",
|
||||
},
|
||||
{
|
||||
name: "korean transliterated",
|
||||
title: "안녕하세요",
|
||||
want: "annyeonghaseyo",
|
||||
},
|
||||
{
|
||||
name: "hebrew transliterated",
|
||||
title: "שלום עולם",
|
||||
want: "shlvm-vlm",
|
||||
},
|
||||
{
|
||||
name: "cyrillic transliterated",
|
||||
title: "Привет мир",
|
||||
want: "privet-mir",
|
||||
},
|
||||
{
|
||||
name: "emoji only falls back to topic",
|
||||
title: "😂😂😂",
|
||||
want: "topic",
|
||||
},
|
||||
{
|
||||
name: "long arabic truncates at cutLongTitle boundary",
|
||||
title: longArabic,
|
||||
want: wantLongArabic,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := UrlTitle(tc.title)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUrlTitleTransliterationToggle(t *testing.T) {
|
||||
defer SetTransliterateNonLatin(true)
|
||||
|
||||
SetTransliterateNonLatin(false)
|
||||
// With transliteration off, pure-Arabic titles collapse to the existing
|
||||
// "topic" fallback (the pre-fix behavior).
|
||||
assert.Equal(t, "topic", UrlTitle("كيف حالك"))
|
||||
|
||||
SetTransliterateNonLatin(true)
|
||||
assert.Equal(t, "kyf-hlk", UrlTitle("كيف حالك"))
|
||||
}
|
||||
|
||||
func TestFindFirstMatchedWord(t *testing.T) {
|
||||
var (
|
||||
expectedWord,
|
||||
|
||||
@@ -488,6 +488,7 @@ export interface AdminSettingsLogin {
|
||||
allow_email_registrations: boolean;
|
||||
allow_email_domains: string[];
|
||||
allow_password_login: boolean;
|
||||
require_email_verification: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -860,6 +861,7 @@ export interface AdminConversationListItem {
|
||||
export interface ConversationDetailItem {
|
||||
chat_completion_id: string;
|
||||
content: string;
|
||||
reasoning_content?: string;
|
||||
role: string;
|
||||
helpful: number;
|
||||
unhelpful: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user