Merge branch 'release/1.0.2' into 'main'

fix(tag): It is not necessary to add a query with reserved tags when searching...

See merge request opensource/answer!395
This commit is contained in:
linkinstar 2023-01-04 03:18:44 +00:00
commit 012bcdc071
73 changed files with 1707 additions and 1782 deletions

View File

@ -1,6 +1,6 @@
.PHONY: build clean ui
VERSION=1.0.1
VERSION=1.0.2
BIN=answer
DIR_SRC=./cmd/answer
DOCKER_CMD=docker

View File

@ -135,7 +135,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
tagCommonService := tag_common2.NewTagCommonService(tagCommonRepo, tagRelRepo, tagRepo, revisionService, siteInfoCommonService)
objService := object_info.NewObjService(answerRepo, questionRepo, commentCommonRepo, tagCommonRepo, tagCommonService)
voteRepo := activity_common.NewVoteRepo(dataData, activityRepo)
commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo)
commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo)
rolePowerRelRepo := role.NewRolePowerRelRepo(dataData)
rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService)
rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configRepo)
@ -166,7 +166,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, questionActivityRepo)
questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, revisionService, metaService, collectionCommon, answerActivityService, dataData)
questionController := controller.NewQuestionController(questionService, rankService)
answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo)
answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService)
dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configRepo, siteInfoCommonService, serviceConf, dataData)
answerController := controller.NewAnswerController(answerService, rankService, dashboardService)
searchParser := search_parser.NewSearchParser(tagCommonService, userCommon)

View File

@ -7,3 +7,6 @@ var Config []byte
//go:embed path_ignore.yaml
var PathIgnore []byte
//go:embed reserved-usernames.json
var ReservedUsernames []byte

File diff suppressed because one or more lines are too long

View File

@ -16,6 +16,22 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/": {
"get": {
"description": "if config file not exist try to redirect to install page",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"installation"
],
"summary": "if config file not exist try to redirect to install page",
"responses": {}
}
},
"/answer/admin/api/answer/page": {
"get": {
"security": [
@ -2687,7 +2703,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserTop",
"parameters": [
@ -2910,7 +2926,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "update question",
"parameters": [
@ -2947,7 +2963,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question",
"parameters": [
@ -2984,7 +3000,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "delete question",
"parameters": [
@ -3023,7 +3039,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "close question msg list",
"responses": {
@ -3043,7 +3059,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "GetQuestion Question",
"description": "get question details",
"consumes": [
"application/json"
],
@ -3051,9 +3067,9 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "GetQuestion Question",
"summary": "get question details",
"parameters": [
{
"type": "string",
@ -3076,7 +3092,7 @@ const docTemplate = `{
},
"/answer/api/v1/question/page": {
"get": {
"description": "SearchQuestionList \u003cbr\u003e \"order\" Enums(newest, active,frequent,score,unanswered)",
"description": "get questions by page",
"consumes": [
"application/json"
],
@ -3084,17 +3100,17 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "SearchQuestionList",
"summary": "get questions by page",
"parameters": [
{
"description": "QuestionSearch",
"description": "QuestionPageReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
"$ref": "#/definitions/schema.QuestionPageReq"
}
}
],
@ -3102,7 +3118,34 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "string"
"allOf": [
{
"$ref": "#/definitions/handler.RespBody"
},
{
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/definitions/pager.PageModel"
},
{
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.QuestionPageResp"
}
}
}
}
]
}
}
}
]
}
}
}
@ -3123,7 +3166,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "reopen question",
"parameters": [
@ -3147,40 +3190,6 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/question/search": {
"post": {
"description": "SearchQuestionList",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"api-question"
],
"summary": "SearchQuestionList",
"parameters": [
{
"description": "QuestionSearch",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/answer/api/v1/question/similar": {
"get": {
"security": [
@ -3196,7 +3205,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question title like",
"parameters": [
@ -3229,7 +3238,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Search Similar Question",
"parameters": [
@ -3267,7 +3276,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Close question",
"parameters": [
@ -4231,6 +4240,29 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/user/email/notification": {
"put": {
"description": "unsubscribe email notification",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "unsubscribe email notification",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/user/email/verification": {
"post": {
"description": "UserVerifyEmail",
@ -5119,7 +5151,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserList",
"parameters": [
@ -6593,27 +6625,112 @@ const docTemplate = `{
}
}
},
"schema.QuestionSearch": {
"schema.QuestionPageReq": {
"type": "object",
"properties": {
"order": {
"description": "Search order by",
"type": "string"
"orderCond": {
"type": "string",
"enum": [
"newest",
"active",
"frequent",
"score",
"unanswered"
]
},
"page": {
"description": "Query number of pages",
"type": "integer"
"type": "integer",
"minimum": 1
},
"page_size": {
"description": "Search page size",
"type": "integer"
"pageSize": {
"type": "integer",
"minimum": 1
},
"tag": {
"description": "Tags []string ` + "`" + `json:\"tags\" form:\"tags\"` + "`" + ` // Search tag",
"type": "string"
"type": "string",
"maxLength": 100
},
"username": {
"type": "string",
"maxLength": 100
}
}
},
"schema.QuestionPageResp": {
"type": "object",
"properties": {
"accepted_answer_id": {
"description": "answer information",
"type": "string"
},
"answer_count": {
"type": "integer"
},
"collection_count": {
"type": "integer"
},
"description": {
"type": "string"
},
"follow_count": {
"type": "integer"
},
"id": {
"type": "string"
},
"last_answer_id": {
"type": "string"
},
"operated_at": {
"description": "operator information",
"type": "integer"
},
"operation_type": {
"type": "string"
},
"operator": {
"$ref": "#/definitions/schema.QuestionPageRespOperator"
},
"status": {
"type": "integer"
},
"tags": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.TagResp"
}
},
"title": {
"type": "string"
},
"unique_view_count": {
"type": "integer"
},
"url_title": {
"type": "string"
},
"view_count": {
"description": "question statistical information",
"type": "integer"
},
"vote_count": {
"type": "integer"
}
}
},
"schema.QuestionPageRespOperator": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"id": {
"type": "string"
},
"rank": {
"type": "integer"
},
"username": {
"description": "Search username",
"type": "string"
}
}

View File

@ -4,6 +4,22 @@
"contact": {}
},
"paths": {
"/": {
"get": {
"description": "if config file not exist try to redirect to install page",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"installation"
],
"summary": "if config file not exist try to redirect to install page",
"responses": {}
}
},
"/answer/admin/api/answer/page": {
"get": {
"security": [
@ -2675,7 +2691,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserTop",
"parameters": [
@ -2898,7 +2914,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "update question",
"parameters": [
@ -2935,7 +2951,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question",
"parameters": [
@ -2972,7 +2988,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "delete question",
"parameters": [
@ -3011,7 +3027,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "close question msg list",
"responses": {
@ -3031,7 +3047,7 @@
"ApiKeyAuth": []
}
],
"description": "GetQuestion Question",
"description": "get question details",
"consumes": [
"application/json"
],
@ -3039,9 +3055,9 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "GetQuestion Question",
"summary": "get question details",
"parameters": [
{
"type": "string",
@ -3064,7 +3080,7 @@
},
"/answer/api/v1/question/page": {
"get": {
"description": "SearchQuestionList \u003cbr\u003e \"order\" Enums(newest, active,frequent,score,unanswered)",
"description": "get questions by page",
"consumes": [
"application/json"
],
@ -3072,17 +3088,17 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "SearchQuestionList",
"summary": "get questions by page",
"parameters": [
{
"description": "QuestionSearch",
"description": "QuestionPageReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
"$ref": "#/definitions/schema.QuestionPageReq"
}
}
],
@ -3090,7 +3106,34 @@
"200": {
"description": "OK",
"schema": {
"type": "string"
"allOf": [
{
"$ref": "#/definitions/handler.RespBody"
},
{
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/definitions/pager.PageModel"
},
{
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.QuestionPageResp"
}
}
}
}
]
}
}
}
]
}
}
}
@ -3111,7 +3154,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "reopen question",
"parameters": [
@ -3135,40 +3178,6 @@
}
}
},
"/answer/api/v1/question/search": {
"post": {
"description": "SearchQuestionList",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"api-question"
],
"summary": "SearchQuestionList",
"parameters": [
{
"description": "QuestionSearch",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/answer/api/v1/question/similar": {
"get": {
"security": [
@ -3184,7 +3193,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question title like",
"parameters": [
@ -3217,7 +3226,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Search Similar Question",
"parameters": [
@ -3255,7 +3264,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Close question",
"parameters": [
@ -4219,6 +4228,29 @@
}
}
},
"/answer/api/v1/user/email/notification": {
"put": {
"description": "unsubscribe email notification",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "unsubscribe email notification",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/user/email/verification": {
"post": {
"description": "UserVerifyEmail",
@ -5107,7 +5139,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserList",
"parameters": [
@ -6581,27 +6613,112 @@
}
}
},
"schema.QuestionSearch": {
"schema.QuestionPageReq": {
"type": "object",
"properties": {
"order": {
"description": "Search order by",
"type": "string"
"orderCond": {
"type": "string",
"enum": [
"newest",
"active",
"frequent",
"score",
"unanswered"
]
},
"page": {
"description": "Query number of pages",
"type": "integer"
"type": "integer",
"minimum": 1
},
"page_size": {
"description": "Search page size",
"type": "integer"
"pageSize": {
"type": "integer",
"minimum": 1
},
"tag": {
"description": "Tags []string `json:\"tags\" form:\"tags\"` // Search tag",
"type": "string"
"type": "string",
"maxLength": 100
},
"username": {
"type": "string",
"maxLength": 100
}
}
},
"schema.QuestionPageResp": {
"type": "object",
"properties": {
"accepted_answer_id": {
"description": "answer information",
"type": "string"
},
"answer_count": {
"type": "integer"
},
"collection_count": {
"type": "integer"
},
"description": {
"type": "string"
},
"follow_count": {
"type": "integer"
},
"id": {
"type": "string"
},
"last_answer_id": {
"type": "string"
},
"operated_at": {
"description": "operator information",
"type": "integer"
},
"operation_type": {
"type": "string"
},
"operator": {
"$ref": "#/definitions/schema.QuestionPageRespOperator"
},
"status": {
"type": "integer"
},
"tags": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.TagResp"
}
},
"title": {
"type": "string"
},
"unique_view_count": {
"type": "integer"
},
"url_title": {
"type": "string"
},
"view_count": {
"description": "question statistical information",
"type": "integer"
},
"vote_count": {
"type": "integer"
}
}
},
"schema.QuestionPageRespOperator": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"id": {
"type": "string"
},
"rank": {
"type": "integer"
},
"username": {
"description": "Search username",
"type": "string"
}
}

View File

@ -1004,23 +1004,80 @@ definitions:
- tags
- title
type: object
schema.QuestionSearch:
schema.QuestionPageReq:
properties:
order:
description: Search order by
orderCond:
enum:
- newest
- active
- frequent
- score
- unanswered
type: string
page:
description: Query number of pages
minimum: 1
type: integer
page_size:
description: Search page size
pageSize:
minimum: 1
type: integer
tag:
description: Tags []string `json:"tags" form:"tags"` // Search
tag
maxLength: 100
type: string
username:
description: Search username
maxLength: 100
type: string
type: object
schema.QuestionPageResp:
properties:
accepted_answer_id:
description: answer information
type: string
answer_count:
type: integer
collection_count:
type: integer
description:
type: string
follow_count:
type: integer
id:
type: string
last_answer_id:
type: string
operated_at:
description: operator information
type: integer
operation_type:
type: string
operator:
$ref: '#/definitions/schema.QuestionPageRespOperator'
status:
type: integer
tags:
items:
$ref: '#/definitions/schema.TagResp'
type: array
title:
type: string
unique_view_count:
type: integer
url_title:
type: string
view_count:
description: question statistical information
type: integer
vote_count:
type: integer
type: object
schema.QuestionPageRespOperator:
properties:
display_name:
type: string
id:
type: string
rank:
type: integer
username:
type: string
type: object
schema.QuestionUpdate:
@ -1886,6 +1943,17 @@ definitions:
info:
contact: {}
paths:
/:
get:
consumes:
- application/json
description: if config file not exist try to redirect to install page
produces:
- application/json
responses: {}
summary: if config file not exist try to redirect to install page
tags:
- installation
/answer/admin/api/answer/page:
get:
consumes:
@ -3517,7 +3585,7 @@ paths:
- ApiKeyAuth: []
summary: UserTop
tags:
- api-question
- Question
/answer/api/v1/personal/rank/page:
get:
description: user personal rank list
@ -3645,7 +3713,7 @@ paths:
- ApiKeyAuth: []
summary: delete question
tags:
- api-question
- Question
post:
consumes:
- application/json
@ -3668,7 +3736,7 @@ paths:
- ApiKeyAuth: []
summary: add question
tags:
- api-question
- Question
put:
consumes:
- application/json
@ -3691,7 +3759,7 @@ paths:
- ApiKeyAuth: []
summary: update question
tags:
- api-question
- Question
/answer/api/v1/question/closemsglist:
get:
consumes:
@ -3708,12 +3776,12 @@ paths:
- ApiKeyAuth: []
summary: close question msg list
tags:
- api-question
- Question
/answer/api/v1/question/info:
get:
consumes:
- application/json
description: GetQuestion Question
description: get question details
parameters:
- default: "1"
description: Question TagID
@ -3730,31 +3798,43 @@ paths:
type: string
security:
- ApiKeyAuth: []
summary: GetQuestion Question
summary: get question details
tags:
- api-question
- Question
/answer/api/v1/question/page:
get:
consumes:
- application/json
description: SearchQuestionList <br> "order" Enums(newest, active,frequent,score,unanswered)
description: get questions by page
parameters:
- description: QuestionSearch
- description: QuestionPageReq
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.QuestionSearch'
$ref: '#/definitions/schema.QuestionPageReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: SearchQuestionList
allOf:
- $ref: '#/definitions/handler.RespBody'
- properties:
data:
allOf:
- $ref: '#/definitions/pager.PageModel'
- properties:
list:
items:
$ref: '#/definitions/schema.QuestionPageResp'
type: array
type: object
type: object
summary: get questions by page
tags:
- api-question
- Question
/answer/api/v1/question/reopen:
put:
consumes:
@ -3778,29 +3858,7 @@ paths:
- ApiKeyAuth: []
summary: reopen question
tags:
- api-question
/answer/api/v1/question/search:
post:
consumes:
- application/json
description: SearchQuestionList
parameters:
- description: QuestionSearch
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.QuestionSearch'
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: SearchQuestionList
tags:
- api-question
- Question
/answer/api/v1/question/similar:
get:
consumes:
@ -3824,7 +3882,7 @@ paths:
- ApiKeyAuth: []
summary: add question title like
tags:
- api-question
- Question
/answer/api/v1/question/similar/tag:
get:
consumes:
@ -3846,7 +3904,7 @@ paths:
type: string
summary: Search Similar Question
tags:
- api-question
- Question
/answer/api/v1/question/status:
put:
consumes:
@ -3870,7 +3928,7 @@ paths:
- ApiKeyAuth: []
summary: Close question
tags:
- api-question
- Question
/answer/api/v1/question/tags:
get:
description: get tag list
@ -4439,6 +4497,21 @@ paths:
summary: send email to the user email then change their email
tags:
- User
/answer/api/v1/user/email/notification:
put:
consumes:
- application/json
description: unsubscribe email notification
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
summary: unsubscribe email notification
tags:
- User
/answer/api/v1/user/email/verification:
post:
consumes:
@ -5007,7 +5080,7 @@ paths:
- ApiKeyAuth: []
summary: UserList
tags:
- api-question
- Question
/robots.txt:
get:
description: get site robots information

View File

@ -111,6 +111,9 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
@ -132,21 +135,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
report:
spam:
name:
@ -201,7 +204,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -838,6 +847,11 @@ ui:
confirm_new_email_invalid: >-
Sorry, this confirmation link is no longer valid. Perhaps your email was
already changed?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -1034,6 +1048,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -4,10 +4,35 @@ import (
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/handler"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/i18n"
)
var (
langMapping = map[i18n.Language]bool{
i18n.LanguageChinese: true,
i18n.LanguageChineseTraditional: true,
i18n.LanguageEnglish: true,
i18n.LanguageGerman: true,
i18n.LanguageSpanish: true,
i18n.LanguageFrench: true,
i18n.LanguageItalian: true,
i18n.LanguageJapanese: true,
i18n.LanguageKorean: true,
i18n.LanguagePortuguese: true,
i18n.LanguageRussian: true,
i18n.LanguageVietnamese: true,
}
)
// 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
lang := handler.GetLang(ctx)
ctx.Set(constant.AcceptLanguageFlag, lang)
if langMapping[lang] {
ctx.Set(constant.AcceptLanguageFlag, lang)
return
}
// default language
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
}

View File

@ -60,4 +60,5 @@ const (
UserCannotUpdateYourRole = "error.user.cannot_update_your_role"
TagCannotSetSynonymAsItself = "error.tag.cannot_set_synonym_as_itself"
NotAllowedRegistration = "error.user.not_allowed_registration"
SMTPConfigFromNameCannotBeEmail = "error.smtp.config_from_name_cannot_be_email"
)

View File

@ -12,8 +12,9 @@ import (
)
const (
DefaultConfigFileName = "config.yaml"
DefaultCacheFileName = "cache.db"
DefaultConfigFileName = "config.yaml"
DefaultCacheFileName = "cache.db"
DefaultReservedUsernamesConfigFileName = "reserved-usernames.json"
)
var (
@ -40,6 +41,7 @@ func InstallAllInitialEnvironment(dataDirPath string) {
FormatAllPath(dataDirPath)
installUploadDir()
installI18nBundle()
installReservedUsernames()
fmt.Println("install all initial environment done")
}
@ -112,3 +114,16 @@ func installI18nBundle() {
}
}
}
func installReservedUsernames() {
reservedUsernamesJsonFilePath := filepath.Join(ConfigFileDir, DefaultReservedUsernamesConfigFileName)
if !dir.CheckFileExist(reservedUsernamesJsonFilePath) {
err := writer.WriteFile(reservedUsernamesJsonFilePath, string(configs.ReservedUsernames))
if err != nil {
fmt.Printf("[%s] write file fail: %s\n", DefaultReservedUsernamesConfigFileName, err)
} else {
fmt.Printf("[%s] write file success\n", DefaultReservedUsernamesConfigFileName)
}
return
}
}

View File

@ -1,10 +1,9 @@
package controller
import (
"context"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/middleware"
"github.com/answerdev/answer/internal/base/pager"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/internal/entity"
@ -31,7 +30,7 @@ func NewQuestionController(questionService *service.QuestionService, rankService
// RemoveQuestion delete question
// @Summary delete question
// @Description delete question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -62,7 +61,7 @@ func (qc *QuestionController) RemoveQuestion(ctx *gin.Context) {
// CloseQuestion Close question
// @Summary Close question
// @Description Close question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -92,7 +91,7 @@ func (qc *QuestionController) CloseQuestion(ctx *gin.Context) {
// ReopenQuestion reopen question
// @Summary reopen question
// @Description reopen question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -119,10 +118,10 @@ func (qc *QuestionController) ReopenQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
}
// GetQuestion godoc
// @Summary GetQuestion Question
// @Description GetQuestion Question
// @Tags api-question
// GetQuestion get question details
// @Summary get question details
// @Description get question details
// @Tags Question
// @Security ApiKeyAuth
// @Accept json
// @Produce json
@ -161,7 +160,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
// SimilarQuestion godoc
// @Summary Search Similar Question
// @Description Search Similar Question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Param question_id query string true "question_id" default()
@ -181,65 +180,34 @@ func (qc *QuestionController) SimilarQuestion(ctx *gin.Context) {
})
}
// Index godoc
// @Summary SearchQuestionList
// @Description SearchQuestionList <br> "order" Enums(newest, active,frequent,score,unanswered)
// @Tags api-question
// QuestionPage get questions by page
// @Summary get questions by page
// @Description get questions by page
// @Tags Question
// @Accept json
// @Produce json
// @Param data body schema.QuestionSearch true "QuestionSearch"
// @Success 200 {string} string ""
// @Param data body schema.QuestionPageReq true "QuestionPageReq"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}}
// @Router /answer/api/v1/question/page [get]
func (qc *QuestionController) Index(ctx *gin.Context) {
req := &schema.QuestionSearch{}
func (qc *QuestionController) QuestionPage(ctx *gin.Context) {
req := &schema.QuestionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
userID := middleware.GetLoginUserIDFromContext(ctx)
list, count, err := qc.questionService.SearchList(ctx, req, userID)
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
questions, total, err := qc.questionService.GetQuestionPage(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, gin.H{
"list": list,
"count": count,
})
}
// SearchList godoc
// @Summary SearchQuestionList
// @Description SearchQuestionList
// @Tags api-question
// @Accept json
// @Produce json
// @Param data body schema.QuestionSearch true "QuestionSearch"
// @Router /answer/api/v1/question/search [post]
// @Success 200 {string} string ""
func (qc *QuestionController) SearchList(c *gin.Context) {
Request := new(schema.QuestionSearch)
err := c.BindJSON(Request)
if err != nil {
handler.HandleResponse(c, err, nil)
return
}
ctx := context.Background()
userID := middleware.GetLoginUserIDFromContext(c)
list, count, err := qc.questionService.SearchList(ctx, Request, userID)
if err != nil {
handler.HandleResponse(c, err, nil)
return
}
handler.HandleResponse(c, nil, gin.H{
"list": list,
"count": count,
})
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions))
}
// AddQuestion add question
// @Summary add question
// @Description add question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -309,7 +277,7 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) {
// UpdateQuestion update question
// @Summary update question
// @Description update question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -366,7 +334,7 @@ func (qc *QuestionController) UpdateQuestion(ctx *gin.Context) {
// CloseMsgList close question msg list
// @Summary close question msg list
// @Description close question msg list
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -380,7 +348,7 @@ func (qc *QuestionController) CloseMsgList(ctx *gin.Context) {
// SearchByTitleLike add question title like
// @Summary add question title like
// @Description add question title like
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -397,7 +365,7 @@ func (qc *QuestionController) SearchByTitleLike(ctx *gin.Context) {
// UserTop godoc
// @Summary UserTop
// @Description UserTop
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -417,7 +385,7 @@ func (qc *QuestionController) UserTop(ctx *gin.Context) {
// UserList godoc
// @Summary UserList
// @Description UserList
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth

View File

@ -91,8 +91,8 @@ func (tc *TemplateController) SiteInfo(ctx *gin.Context) *schema.TemplateSiteInf
// Index question list
func (tc *TemplateController) Index(ctx *gin.Context) {
req := &schema.QuestionSearch{
Order: "newest",
req := &schema.QuestionPageReq{
OrderCond: "newest",
}
if handler.BindAndCheck(ctx, req) {
tc.Page404(ctx)
@ -124,8 +124,8 @@ func (tc *TemplateController) Index(ctx *gin.Context) {
}
func (tc *TemplateController) QuestionList(ctx *gin.Context) {
req := &schema.QuestionSearch{
Order: "newest",
req := &schema.QuestionPageReq{
OrderCond: "newest",
}
if handler.BindAndCheck(ctx, req) {
tc.Page404(ctx)
@ -152,7 +152,7 @@ func (tc *TemplateController) QuestionList(ctx *gin.Context) {
})
}
func (tc *TemplateController) QuestionInfo301Jump(ctx *gin.Context, siteInfo *schema.TemplateSiteInfoResp, correctTitle bool) (jump bool, url string) {
func (tc *TemplateController) QuestionInfoeRdirect(ctx *gin.Context, siteInfo *schema.TemplateSiteInfoResp, correctTitle bool) (jump bool, url string) {
id := ctx.Param("id")
title := ctx.Param("title")
titleIsAnswerID := false
@ -182,6 +182,9 @@ func (tc *TemplateController) QuestionInfo301Jump(ctx *gin.Context, siteInfo *sc
return
}
url = fmt.Sprintf("%s/%s", url, htmltext.UrlTitle(detail.Title))
if titleIsAnswerID {
url = fmt.Sprintf("%s/%s", url, title)
}
return true, url
}
}
@ -217,9 +220,9 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) {
}
siteInfo := tc.SiteInfo(ctx)
jump, jumpurl := tc.QuestionInfo301Jump(ctx, siteInfo, correctTitle)
jump, jumpurl := tc.QuestionInfoeRdirect(ctx, siteInfo, correctTitle)
if jump {
ctx.Redirect(http.StatusMovedPermanently, jumpurl)
ctx.Redirect(http.StatusFound, jumpurl)
return
}

View File

@ -11,8 +11,8 @@ import (
"github.com/segmentfault/pacman/log"
)
func (t *TemplateRenderController) Index(ctx *gin.Context, req *schema.QuestionSearch) ([]*schema.QuestionInfo, int64, error) {
return t.questionService.SearchList(ctx, req, req.UserID)
func (t *TemplateRenderController) Index(ctx *gin.Context, req *schema.QuestionPageReq) ([]*schema.QuestionPageResp, int64, error) {
return t.questionService.GetQuestionPage(ctx, req)
}
func (t *TemplateRenderController) QuestionDetail(ctx *gin.Context, id string) (resp *schema.QuestionInfo, err error) {

View File

@ -15,19 +15,20 @@ func (q *TemplateRenderController) TagList(ctx context.Context, req *schema.GetT
return
}
func (q *TemplateRenderController) TagInfo(ctx context.Context, req *schema.GetTamplateTagInfoReq) (resp *schema.GetTagResp, questionList []*schema.QuestionInfo, questionCount int64, err error) {
func (q *TemplateRenderController) TagInfo(ctx context.Context, req *schema.GetTamplateTagInfoReq) (resp *schema.GetTagResp, questionList []*schema.QuestionPageResp, questionCount int64, err error) {
dto := &schema.GetTagInfoReq{}
_ = copier.Copy(dto, req)
resp, err = q.tagService.GetTagInfo(ctx, dto)
if err != nil {
return
}
searchQuestion := &schema.QuestionSearch{}
searchQuestion := &schema.QuestionPageReq{}
searchQuestion.Page = req.Page
searchQuestion.PageSize = req.PageSize
searchQuestion.Order = "newest"
searchQuestion.OrderCond = "newest"
searchQuestion.Tag = req.Name
questionList, questionCount, err = q.questionService.SearchList(ctx, searchQuestion, "")
searchQuestion.LoginUserID = req.UserID
questionList, questionCount, err = q.questionService.GetQuestionPage(ctx, searchQuestion)
if err != nil {
return
}

View File

@ -242,8 +242,15 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
return
}
resp, err := uc.userService.UserRegisterByEmail(ctx, req)
handler.HandleResponse(ctx, err, resp)
resp, errFields, err := uc.userService.UserRegisterByEmail(ctx, req)
if len(errFields) > 0 {
for _, field := range errFields {
field.ErrorMsg = translator.GlobalTrans.Tr(handler.GetLang(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
} else {
handler.HandleResponse(ctx, err, resp)
}
}
// UserVerifyEmail godoc
@ -377,8 +384,11 @@ func (uc *UserController) UserUpdateInfo(ctx *gin.Context) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := uc.userService.UpdateInfo(ctx, req)
handler.HandleResponse(ctx, err, nil)
errFields, err := uc.userService.UpdateInfo(ctx, req)
for _, field := range errFields {
field.ErrorMsg = translator.GlobalTrans.Tr(handler.GetLang(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
}
// UserUpdateInterface update user interface config
@ -535,3 +545,28 @@ func (uc *UserController) UserRanking(ctx *gin.Context) {
resp, err := uc.userService.UserRanking(ctx)
handler.HandleResponse(ctx, err, resp)
}
// UserUnsubscribeEmailNotification unsubscribe email notification
// @Summary unsubscribe email notification
// @Description unsubscribe email notification
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/user/email/notification [put]
func (uc *UserController) UserUnsubscribeEmailNotification(ctx *gin.Context) {
req := &schema.UserUnsubscribeEmailNotificationReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.Content = uc.emailService.VerifyUrlExpired(ctx, req.Code)
if len(req.Content) == 0 {
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailVerifyURLExpired),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeURLExpired})
return
}
err := uc.userService.UserUnsubscribeEmailNotification(ctx, req)
handler.HandleResponse(ctx, err, nil)
}

View File

@ -22,11 +22,6 @@ var AdminQuestionSearchStatusIntToString = map[int]string{
QuestionStatusDeleted: "deleted",
}
type QuestionTag struct {
Question `xorm:"extends"`
TagRel `xorm:"extends"`
}
// Question question
type Question struct {
ID string `xorm:"not null pk BIGINT(20) id"`

View File

@ -1,6 +1,7 @@
package install
import (
"net/http"
"os"
"path/filepath"
"time"
@ -30,6 +31,21 @@ func LangOptions(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, translator.LanguageOptions)
}
// CheckConfigFileAndRedirectToInstallPage if config file not exist try to redirect to install page
// @Summary if config file not exist try to redirect to install page
// @Description if config file not exist try to redirect to install page
// @Tags installation
// @Accept json
// @Produce json
// @Router / [get]
func CheckConfigFileAndRedirectToInstallPage(ctx *gin.Context) {
if cli.CheckConfigFile(confPath) {
ctx.Redirect(http.StatusFound, "/50x")
} else {
ctx.Redirect(http.StatusFound, "/install")
}
}
// CheckConfigFile check config file if exist when installation
// @Summary check config file if exist when installation
// @Description check config file if exist when installation

View File

@ -34,6 +34,7 @@ func NewInstallHTTPServer() *gin.Engine {
}))
installApi := r.Group("")
installApi.GET("/", CheckConfigFileAndRedirectToInstallPage)
installApi.GET("/install", WebPage)
installApi.GET("/50x", WebPage)
installApi.GET("/installation/language/options", LangOptions)

View File

@ -260,7 +260,7 @@ func initConfigTable(engine *xorm.Engine) error {
{ID: 30, Key: "answer.vote_up", Value: `0`},
{ID: 31, Key: "answer.vote_up_cancel", Value: `0`},
{ID: 32, Key: "question.follow", Value: `0`},
{ID: 33, Key: "email.config", Value: `{"from_name":"","from_email":"","smtp_host":"","smtp_port":465,"smtp_password":"","smtp_username":"","smtp_authentication":true,"encryption":"","register_title":"[{{.SiteName}}] Confirm your new account","register_body":"Welcome to {{.SiteName}}<br><br>\n\nClick the following link to confirm and activate your new account:<br>\n<a href='{{.RegisterUrl}}' target='_blank'>{{.RegisterUrl}}</a><br><br>\n\nIf the above link is not clickable, try copying and pasting it into the address bar of your web browser.\n","pass_reset_title":"[{{.SiteName }}] Password reset","pass_reset_body":"Somebody asked to reset your password on [{{.SiteName}}].<br><br>\n\nIf it was not you, you can safely ignore this email.<br><br>\n\nClick the following link to choose a new password:<br>\n<a href='{{.PassResetUrl}}' target='_blank'>{{.PassResetUrl}}</a>\n","change_title":"[{{.SiteName}}] Confirm your new email address","change_body":"Confirm your new email address for {{.SiteName}} by clicking on the following link:<br><br>\n\n<a href='{{.ChangeEmailUrl}}' target='_blank'>{{.ChangeEmailUrl}}</a><br><br>\n\nIf you did not request this change, please ignore this email.\n","test_title":"[{{.SiteName}}] Test Email","test_body":"This is a test email."}`},
{ID: 33, Key: "email.config", Value: `{"from_name":"","from_email":"","smtp_host":"","smtp_port":465,"smtp_password":"","smtp_username":"","smtp_authentication":true,"encryption":"","register_title":"[{{.SiteName}}] Confirm your new account","register_body":"Welcome to {{.SiteName}}<br><br>\n\nClick the following link to confirm and activate your new account:<br>\n<a href='{{.RegisterUrl}}' target='_blank'>{{.RegisterUrl}}</a><br><br>\n\nIf the above link is not clickable, try copying and pasting it into the address bar of your web browser.\n","pass_reset_title":"[{{.SiteName }}] Password reset","pass_reset_body":"Somebody asked to reset your password on [{{.SiteName}}].<br><br>\n\nIf it was not you, you can safely ignore this email.<br><br>\n\nClick the following link to choose a new password:<br>\n<a href='{{.PassResetUrl}}' target='_blank'>{{.PassResetUrl}}</a>\n","change_title":"[{{.SiteName}}] Confirm your new email address","change_body":"Confirm your new email address for {{.SiteName}} by clicking on the following link:<br><br>\n\n<a href='{{.ChangeEmailUrl}}' target='_blank'>{{.ChangeEmailUrl}}</a><br><br>\n\nIf you did not request this change, please ignore this email.\n","test_title":"[{{.SiteName}}] Test Email","test_body":"This is a test email.","new_answer_title":"[{{.SiteName}}] {{.DisplayName}} answered your question","new_answer_body":"<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>","new_comment_title":"[{{.SiteName}}] {{.DisplayName}} commented on your post","new_comment_body":"<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"}`},
{ID: 35, Key: "tag.follow", Value: `0`},
{ID: 36, Key: "rank.question.add", Value: `1`},
{ID: 37, Key: "rank.question.edit", Value: `200`},

View File

@ -55,6 +55,7 @@ var migrations = []Migration{
NewMigration("add activity timeline", addActivityTimeline, false),
NewMigration("add user role", addRoleFeatures, false),
NewMigration("add theme and private mode", addThemeAndPrivateMode, true),
NewMigration("add new answer notification", addNewAnswerNotification, true),
}
// GetCurrentDBVersion returns the current db version

View File

@ -25,6 +25,9 @@ func addThemeAndPrivateMode(x *xorm.Engine) error {
}
if !exist {
_, err = x.InsertOne(siteInfo)
if err != nil {
return fmt.Errorf("insert site info failed: %w", err)
}
}
themeConfig := `{"theme":"default","theme_config":{"default":{"navbar_style":"colored","primary_color":"#0033ff"}}}`

41
internal/migrations/v6.go Normal file
View File

@ -0,0 +1,41 @@
package migrations
import (
"encoding/json"
"fmt"
"github.com/answerdev/answer/internal/entity"
"xorm.io/xorm"
)
func addNewAnswerNotification(x *xorm.Engine) error {
cond := &entity.Config{Key: "email.config"}
exists, err := x.Get(cond)
if err != nil {
return fmt.Errorf("get email config failed: %w", err)
}
if !exists {
// This should be impossible except that the config was deleted manually by user.
_, err = x.InsertOne(&entity.Config{
Key: "email.config",
Value: `{"from_name":"","from_email":"","smtp_host":"","smtp_port":465,"smtp_password":"","smtp_username":"","smtp_authentication":true,"encryption":"","register_title":"[{{.SiteName}}] Confirm your new account","register_body":"Welcome to {{.SiteName}}<br><br>\n\nClick the following link to confirm and activate your new account:<br>\n<a href='{{.RegisterUrl}}' target='_blank'>{{.RegisterUrl}}</a><br><br>\n\nIf the above link is not clickable, try copying and pasting it into the address bar of your web browser.\n","pass_reset_title":"[{{.SiteName }}] Password reset","pass_reset_body":"Somebody asked to reset your password on [{{.SiteName}}].<br><br>\n\nIf it was not you, you can safely ignore this email.<br><br>\n\nClick the following link to choose a new password:<br>\n<a href='{{.PassResetUrl}}' target='_blank'>{{.PassResetUrl}}</a>\n","change_title":"[{{.SiteName}}] Confirm your new email address","change_body":"Confirm your new email address for {{.SiteName}} by clicking on the following link:<br><br>\n\n<a href='{{.ChangeEmailUrl}}' target='_blank'>{{.ChangeEmailUrl}}</a><br><br>\n\nIf you did not request this change, please ignore this email.\n","test_title":"[{{.SiteName}}] Test Email","test_body":"This is a test email.","new_answer_title":"[{{.SiteName}}] {{.DisplayName}} answered your question","new_answer_body":"<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>","new_comment_title":"[{{.SiteName}}] {{.DisplayName}} commented on your post","new_comment_body":"<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"}`,
})
if err != nil {
return fmt.Errorf("add email config failed: %v", err)
}
}
m := make(map[string]interface{})
_ = json.Unmarshal([]byte(cond.Value), &m)
m["new_answer_title"] = "[{{.SiteName}}] {{.DisplayName}} answered your question"
m["new_answer_body"] = "<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
m["new_comment_title"] = "[{{.SiteName}}] {{.DisplayName}} commented on your post"
m["new_comment_body"] = "<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
val, _ := json.Marshal(m)
_, err = x.ID(cond.ID).Update(&entity.Config{Value: string(val)})
if err != nil {
return fmt.Errorf("update email config failed: %v", err)
}
return nil
}

View File

@ -23,8 +23,8 @@ func NewEmailRepo(data *data.Data) export.EmailRepo {
}
// SetCode The email code is used to verify that the link in the message is out of date
func (e *emailRepo) SetCode(ctx context.Context, code, content string) error {
err := e.data.Cache.SetString(ctx, code, content, 10*time.Minute)
func (e *emailRepo) SetCode(ctx context.Context, code, content string, duration time.Duration) error {
err := e.data.Cache.SetString(ctx, code, content, duration)
if err != nil {
return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

View File

@ -205,70 +205,40 @@ func (qr *questionRepo) GetQuestionIDsPage(ctx context.Context, page, pageSize i
return questionIDList, nil
}
// GetQuestionPage get question page
func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, question *entity.Question) (questionList []*entity.Question, total int64, err error) {
// GetQuestionPage query question page
func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string) (
questionList []*entity.Question, total int64, err error) {
questionList = make([]*entity.Question, 0)
total, err = pager.Help(page, pageSize, questionList, question, qr.data.DB.NewSession())
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
return
}
// SearchList
func (qr *questionRepo) SearchList(ctx context.Context, search *schema.QuestionSearch) ([]*entity.QuestionTag, int64, error) {
var count int64
var err error
rows := make([]*entity.QuestionTag, 0)
if search.Page > 0 {
search.Page = search.Page - 1
} else {
search.Page = 0
session := qr.data.DB.Where("question.status = ? OR question.status = ?",
entity.QuestionStatusAvailable, entity.QuestionStatusClosed)
if len(tagID) > 0 {
session.Join("LEFT", "tag_rel", "question.id = tag_rel.object_id")
session.And("tag_rel.tag_id = ?", tagID)
session.And("tag_rel.status = ?", entity.TagRelStatusAvailable)
}
if search.PageSize == 0 {
search.PageSize = constant.DefaultPageSize
if len(userID) > 0 {
session.And("question.user_id = ?", userID)
}
offset := search.Page * search.PageSize
session := qr.data.DB.Table("question")
if len(search.TagIDs) > 0 {
session = session.Join("LEFT", "tag_rel", "question.id = tag_rel.object_id")
session = session.And("tag_rel.tag_id =?", search.TagIDs[0])
// session = session.In("tag_rel.tag_id ", search.TagIDs)
session = session.And("tag_rel.status =?", entity.TagRelStatusAvailable)
}
if len(search.UserID) > 0 {
session = session.And("question.user_id = ?", search.UserID)
}
session = session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
// if search.Status > 0 {
// session = session.And("question.status = ?", search.Status)
// }
// switch
// newest, active,frequent,score,unanswered
switch search.Order {
switch orderCond {
case "newest":
session = session.OrderBy("question.created_at desc")
session.OrderBy("question.created_at DESC")
case "active":
session = session.OrderBy("question.post_update_time desc,question.updated_at desc")
session.OrderBy("question.post_update_time DESC, question.updated_at DESC")
case "frequent":
session = session.OrderBy("question.view_count desc")
session.OrderBy("question.view_count DESC")
case "score":
session = session.OrderBy("question.vote_count desc,question.view_count desc")
session.OrderBy("question.vote_count DESC, question.view_count DESC")
case "unanswered":
session = session.And("question.last_answer_id = 0")
session = session.OrderBy("question.created_at desc")
session.Where("question.last_answer_id = 0")
session.OrderBy("question.created_at DESC")
}
session = session.Limit(search.PageSize, offset)
session = session.Select("question.id,question.user_id,last_edit_user_id,question.title,question.original_text,question.parsed_text,question.status,question.view_count,question.unique_view_count,question.vote_count,question.answer_count,question.collection_count,question.follow_count,question.accepted_answer_id,question.last_answer_id,question.created_at,question.updated_at,question.post_update_time,question.revision_id")
count, err = session.FindAndCount(&rows)
total, err = pager.Help(page, pageSize, &questionList, &entity.Question{}, session)
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
return rows, count, err
}
return rows, count, nil
return questionList, total, err
}
func (qr *questionRepo) AdminSearchList(ctx context.Context, search *schema.AdminQuestionSearch) ([]*entity.Question, int64, error) {

View File

@ -3,6 +3,7 @@ package repo_test
import (
"context"
"testing"
"time"
"github.com/answerdev/answer/internal/repo/export"
"github.com/stretchr/testify/assert"
@ -11,7 +12,7 @@ import (
func Test_emailRepo_VerifyCode(t *testing.T) {
emailRepo := export.NewEmailRepo(testDataSource)
code, content := "1111", "test"
err := emailRepo.SetCode(context.TODO(), code, content)
err := emailRepo.SetCode(context.TODO(), code, content, time.Minute)
assert.NoError(t, err)
verifyContent, err := emailRepo.VerifyCode(context.TODO(), code)

View File

@ -29,7 +29,7 @@ var (
"`question`.`id` as `question_id`",
"`title`",
"`parsed_text`",
"`question`.`created_at`",
"`question`.`created_at` as `created_at`",
"`user_id`",
"`vote_count`",
"`answer_count`",
@ -42,7 +42,7 @@ var (
"`question_id`",
"`question`.`title` as `title`",
"`answer`.`parsed_text` as `parsed_text`",
"`answer`.`created_at`",
"`answer`.`created_at` as `created_at`",
"`answer`.`user_id` as `user_id`",
"`answer`.`vote_count` as `vote_count`",
"0 as `answer_count`",
@ -466,6 +466,7 @@ func (sr *searchRepo) parseResult(ctx context.Context, res []map[string][]byte)
Where(builder.Eq{"tag_rel.object_id": r["question_id"]}).
And(builder.Eq{"tag_rel.status": entity.TagRelStatusAvailable}).
UseBool("recommend", "reserved").
OrderBy("tag.recommend DESC, tag.reserved DESC, tag.id DESC").
Find(&tagsEntity)
if err != nil {

View File

@ -62,16 +62,11 @@ func (tr *tagCommonRepo) GetTagListByName(ctx context.Context, name string, hasR
if name != "" {
session.Where("slug_name LIKE ?", name+"%")
} else {
session.UseBool("recommend")
cond.Recommend = true
}
session.Where(builder.Eq{"status": entity.TagStatusAvailable})
session.Asc("slug_name")
// if !hasReserved {
// cond.Reserved = false
// session.UseBool("recommend", "reserved")
// } else {
session.UseBool("recommend")
// }
err = session.OrderBy("recommend desc,reserved desc,id desc").Find(&tagList, cond)
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()

View File

@ -106,6 +106,7 @@ func (a *AnswerAPIRouter) RegisterMustUnAuthAnswerAPIRouter(r *gin.RouterGroup)
r.POST("/user/password/reset", a.userController.RetrievePassWord)
r.POST("/user/password/replacement", a.userController.UseRePassWord)
r.GET("/user/info", a.userController.GetUserInfoByUserID)
r.PUT("/user/email/notification", a.userController.UserUnsubscribeEmailNotification)
}
func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
@ -123,8 +124,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
//question
r.GET("/question/info", a.questionController.GetQuestion)
r.POST("/question/search", a.questionController.SearchList)
r.GET("/question/page", a.questionController.Index)
r.GET("/question/page", a.questionController.QuestionPage)
r.GET("/question/similar/tag", a.questionController.SimilarQuestion)
r.GET("/personal/qa/top", a.questionController.UserTop)
r.GET("/personal/question/page", a.questionController.UserList)
@ -142,7 +142,6 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
r.GET("/tags/following", a.tagController.GetFollowingTags)
r.GET("/tag", a.tagController.GetTagInfo)
r.GET("/tag/synonyms", a.tagController.GetTagSynonyms)
r.GET("/question/index", a.questionController.Index)
//search
r.GET("/search", a.searchController.Search)

View File

@ -0,0 +1,64 @@
package schema
import "encoding/json"
const (
AccountActivationSourceType SourceType = "account-activation"
PasswordResetSourceType SourceType = "password-reset"
ConfirmNewEmailSourceType SourceType = "password-reset"
UnsubscribeSourceType SourceType = "unsubscribe"
)
type SourceType string
type EmailCodeContent struct {
SourceType SourceType `json:"source_type"`
Email string `json:"e_mail"`
UserID string `json:"user_id"`
}
func (r *EmailCodeContent) ToJSONString() string {
codeBytes, _ := json.Marshal(r)
return string(codeBytes)
}
func (r *EmailCodeContent) FromJSONString(data string) error {
return json.Unmarshal([]byte(data), &r)
}
type NewAnswerTemplateRawData struct {
AnswerUserDisplayName string
QuestionTitle string
QuestionID string
AnswerID string
AnswerSummary string
UnsubscribeCode string
}
type NewAnswerTemplateData struct {
SiteName string
DisplayName string
QuestionTitle string
AnswerUrl string
AnswerSummary string
UnsubscribeUrl string
}
type NewCommentTemplateRawData struct {
CommentUserDisplayName string
QuestionTitle string
QuestionID string
AnswerID string
CommentID string
CommentSummary string
UnsubscribeCode string
}
type NewCommentTemplateData struct {
SiteName string
DisplayName string
QuestionTitle string
CommentUrl string
CommentSummary string
UnsubscribeUrl string
}

View File

@ -1,6 +1,8 @@
package schema
import (
"time"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/pkg/converter"
)
@ -222,15 +224,66 @@ type UserQuestionInfo struct {
Status string `json:"status"`
}
type QuestionSearch struct {
Page int `json:"page" form:"page"` // Query number of pages
PageSize int `json:"page_size" form:"page_size"` // Search page size
Order string `json:"order" form:"order"` // Search order by
// Tags []string `json:"tags" form:"tags"` // Search tag
Tag string `json:"tag" form:"tag"` //Search tag
TagIDs []string `json:"-" form:"-"` // Search tag
UserName string `json:"username" form:"username"` // Search username
UserID string `json:"-" form:"-"`
const (
QuestionOrderCondNewest = "newest"
QuestionOrderCondActive = "active"
QuestionOrderCondFrequent = "frequent"
QuestionOrderCondScore = "score"
QuestionOrderCondUnanswered = "unanswered"
)
// QuestionPageReq query questions page
type QuestionPageReq struct {
Page int `validate:"omitempty,min=1" form:"page"`
PageSize int `validate:"omitempty,min=1" form:"page_size"`
OrderCond string `validate:"omitempty,oneof=newest active frequent score unanswered" form:"order"`
Tag string `validate:"omitempty,gt=0,lte=100" form:"tag"`
Username string `validate:"omitempty,gt=0,lte=100" form:"username"`
LoginUserID string `json:"-"`
UserIDBeSearched string `json:"-"`
TagID string `json:"-"`
}
const (
QuestionPageRespOperationTypeAsked = "question.operation_type.asked"
QuestionPageRespOperationTypeAnswered = "question.operation_type.answered"
QuestionPageRespOperationTypeModified = "question.operation_type.modified"
)
type QuestionPageResp struct {
ID string `json:"id" `
Title string `json:"title"`
UrlTitle string `json:"url_title"`
Description string `json:"description"`
Status int `json:"status"`
Tags []*TagResp `json:"tags"`
// question statistical information
ViewCount int `json:"view_count"`
UniqueViewCount int `json:"unique_view_count"`
VoteCount int `json:"vote_count"`
AnswerCount int `json:"answer_count"`
CollectionCount int `json:"collection_count"`
FollowCount int `json:"follow_count"`
// answer information
AcceptedAnswerID string `json:"accepted_answer_id"`
LastAnswerID string `json:"last_answer_id"`
LastAnsweredUserID string `json:"-"`
LastAnsweredAt time.Time `json:"-"`
// operator information
OperatedAt int64 `json:"operated_at"`
Operator *QuestionPageRespOperator `json:"operator"`
OperationType string `json:"operation_type"`
}
type QuestionPageRespOperator struct {
ID string `json:"id"`
Username string `json:"username"`
Rank int `json:"rank"`
DisplayName string `json:"display_name"`
}
type AdminQuestionSearch struct {

View File

@ -26,6 +26,7 @@ type SearchObject struct {
}
type TagResp struct {
ID string `json:"-"`
SlugName string `json:"slug_name"`
DisplayName string `json:"display_name"`
// if main tag slug name is not empty, this tag is synonymous with the main tag

View File

@ -3,10 +3,14 @@ package schema
import (
"context"
"fmt"
"net/mail"
"net/url"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/base/validator"
"github.com/segmentfault/pacman/errors"
)
const PermaLinkQuestionIDAndTitle = 1
@ -190,6 +194,17 @@ type UpdateSMTPConfigReq struct {
TestEmailRecipient string `validate:"omitempty,email" json:"test_email_recipient"`
}
func (r *UpdateSMTPConfigReq) Check() (errField []*validator.FormErrorField, err error) {
_, err = mail.ParseAddress(r.FromName)
if err == nil {
return append(errField, &validator.FormErrorField{
ErrorField: "from_name",
ErrorMsg: reason.SMTPConfigFromNameCannotBeEmail,
}), errors.BadRequest(reason.SMTPConfigFromNameCannotBeEmail)
}
return nil, nil
}
// GetSMTPConfigResp get smtp config response
type GetSMTPConfigResp struct {
FromEmail string `json:"from_email"`

View File

@ -349,8 +349,8 @@ func (u *UserRePassWordRequest) Check() (errFields []*validator.FormErrorField,
}
type UserNoticeSetRequest struct {
UserID string `json:"-" ` // user_id
NoticeSwitch bool `json:"notice_switch" `
NoticeSwitch bool `json:"notice_switch"`
UserID string `json:"-"`
}
type UserNoticeSetResp struct {
@ -396,20 +396,6 @@ type UserChangeEmailSendCodeReq struct {
UserID string `json:"-"`
}
type EmailCodeContent struct {
Email string `json:"e_mail"`
UserID string `json:"user_id"`
}
func (r *EmailCodeContent) ToJSONString() string {
codeBytes, _ := json.Marshal(r)
return string(codeBytes)
}
func (r *EmailCodeContent) FromJSONString(data string) error {
return json.Unmarshal([]byte(data), &r)
}
type UserChangeEmailVerifyReq struct {
Code string `validate:"required,gt=0,lte=500" json:"code"`
Content string `json:"-"`
@ -440,3 +426,9 @@ type UserRankingSimpleInfo struct {
// avatar
Avatar string `json:"avatar"`
}
// UserUnsubscribeEmailNotificationReq user unsubscribe email notification request
type UserUnsubscribeEmailNotificationReq struct {
Code string `validate:"required,gt=0,lte=500" json:"code"`
Content string `json:"-"`
}

View File

@ -15,11 +15,13 @@ import (
"github.com/answerdev/answer/internal/service/activity_queue"
answercommon "github.com/answerdev/answer/internal/service/answer_common"
collectioncommon "github.com/answerdev/answer/internal/service/collection_common"
"github.com/answerdev/answer/internal/service/export"
"github.com/answerdev/answer/internal/service/notice_queue"
"github.com/answerdev/answer/internal/service/permission"
questioncommon "github.com/answerdev/answer/internal/service/question_common"
"github.com/answerdev/answer/internal/service/revision_common"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/encryption"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
)
@ -36,6 +38,7 @@ type AnswerService struct {
revisionService *revision_common.RevisionService
AnswerCommon *answercommon.AnswerCommon
voteRepo activity_common.VoteRepo
emailService *export.EmailService
}
func NewAnswerService(
@ -49,6 +52,7 @@ func NewAnswerService(
answerAcceptActivityRepo *activity.AnswerActivityService,
answerCommon *answercommon.AnswerCommon,
voteRepo activity_common.VoteRepo,
emailService *export.EmailService,
) *AnswerService {
return &AnswerService{
answerRepo: answerRepo,
@ -61,6 +65,7 @@ func NewAnswerService(
answerActivityService: answerAcceptActivityRepo,
AnswerCommon: answerCommon,
voteRepo: voteRepo,
emailService: emailService,
}
}
@ -176,7 +181,8 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) (
if err != nil {
return insertData.ID, err
}
as.notificationAnswerTheQuestion(ctx, questionInfo.UserID, insertData.ID, req.UserID)
as.notificationAnswerTheQuestion(ctx, questionInfo.UserID, questionInfo.ID, insertData.ID, req.UserID, questionInfo.Title,
insertData.OriginalText)
activity_queue.AddActivity(&schema.ActivityMsg{
UserID: insertData.UserID,
@ -542,7 +548,12 @@ func (as *AnswerService) notificationUpdateAnswer(ctx context.Context, questionU
notice_queue.AddNotification(msg)
}
func (as *AnswerService) notificationAnswerTheQuestion(ctx context.Context, questionUserID, answerID, answerUserID string) {
func (as *AnswerService) notificationAnswerTheQuestion(ctx context.Context,
questionUserID, questionID, answerID, answerUserID, questionTitle, answerSummary string) {
// If the question is answered by me, there is no notification for myself.
if questionUserID == answerUserID {
return
}
msg := &schema.NotificationMsg{
TriggerUserID: answerUserID,
ReceiverUserID: questionUserID,
@ -552,4 +563,43 @@ func (as *AnswerService) notificationAnswerTheQuestion(ctx context.Context, ques
msg.ObjectType = constant.AnswerObjectType
msg.NotificationAction = constant.AnswerTheQuestion
notice_queue.AddNotification(msg)
userInfo, exist, err := as.userRepo.GetByUserID(ctx, questionUserID)
if err != nil {
log.Error(err)
return
}
if !exist {
log.Warnf("user %s not found", questionUserID)
return
}
if userInfo.NoticeStatus == schema.NoticeStatusOff || len(userInfo.EMail) == 0 {
return
}
rawData := &schema.NewAnswerTemplateRawData{
QuestionTitle: questionTitle,
QuestionID: questionID,
AnswerID: answerID,
AnswerSummary: answerSummary,
UnsubscribeCode: encryption.MD5(userInfo.Pass),
}
answerUser, _, _ := as.userCommon.GetUserBasicInfoByID(ctx, answerUserID)
if answerUser != nil {
rawData.AnswerUserDisplayName = answerUser.DisplayName
}
codeContent := &schema.EmailCodeContent{
SourceType: schema.UnsubscribeSourceType,
Email: userInfo.EMail,
UserID: userInfo.ID,
}
title, body, err := as.emailService.NewAnswerTemplate(ctx, rawData)
if err != nil {
log.Error(err)
return
}
go as.emailService.SendAndSaveCodeWithTime(
ctx, userInfo.EMail, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 7*24*time.Hour)
}

View File

@ -2,6 +2,7 @@ package comment
import (
"context"
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/pager"
@ -11,10 +12,12 @@ import (
"github.com/answerdev/answer/internal/service/activity_common"
"github.com/answerdev/answer/internal/service/activity_queue"
"github.com/answerdev/answer/internal/service/comment_common"
"github.com/answerdev/answer/internal/service/export"
"github.com/answerdev/answer/internal/service/notice_queue"
"github.com/answerdev/answer/internal/service/object_info"
"github.com/answerdev/answer/internal/service/permission"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/encryption"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
@ -30,15 +33,6 @@ type CommentRepo interface {
comments []*entity.Comment, total int64, err error)
}
// CommentService user service
type CommentService struct {
commentRepo CommentRepo
commentCommonRepo comment_common.CommentCommonRepo
userCommon *usercommon.UserCommon
voteCommon activity_common.VoteRepo
objectInfoService *object_info.ObjService
}
type CommentQuery struct {
pager.PageCond
// object id
@ -59,19 +53,35 @@ func (c *CommentQuery) GetOrderBy() string {
return "created_at ASC"
}
// CommentService user service
type CommentService struct {
commentRepo CommentRepo
commentCommonRepo comment_common.CommentCommonRepo
userCommon *usercommon.UserCommon
voteCommon activity_common.VoteRepo
objectInfoService *object_info.ObjService
emailService *export.EmailService
userRepo usercommon.UserRepo
}
// NewCommentService new comment service
func NewCommentService(
commentRepo CommentRepo,
commentCommonRepo comment_common.CommentCommonRepo,
userCommon *usercommon.UserCommon,
objectInfoService *object_info.ObjService,
voteCommon activity_common.VoteRepo) *CommentService {
voteCommon activity_common.VoteRepo,
emailService *export.EmailService,
userRepo usercommon.UserRepo,
) *CommentService {
return &CommentService{
commentRepo: commentRepo,
commentCommonRepo: commentCommonRepo,
userCommon: userCommon,
voteCommon: voteCommon,
objectInfoService: objectInfoService,
emailService: emailService,
userRepo: userRepo,
}
}
@ -112,9 +122,11 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
}
if objInfo.ObjectType == constant.QuestionObjectType {
cs.notificationQuestionComment(ctx, objInfo.ObjectCreatorUserID, comment.ID, req.UserID)
cs.notificationQuestionComment(ctx, objInfo.ObjectCreatorUserID,
objInfo.QuestionID, objInfo.Title, comment.ID, req.UserID, comment.OriginalText)
} else if objInfo.ObjectType == constant.AnswerObjectType {
cs.notificationAnswerComment(ctx, objInfo.ObjectCreatorUserID, comment.ID, req.UserID)
cs.notificationAnswerComment(ctx, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID,
objInfo.ObjectCreatorUserID, comment.ID, req.UserID, comment.OriginalText)
}
if len(req.MentionUsernameList) > 0 {
cs.notificationMention(ctx, req.MentionUsernameList, comment.ID, req.UserID)
@ -331,21 +343,6 @@ func (cs *CommentService) convertCommentEntity2Resp(ctx context.Context, req *sc
return commentResp, nil
}
func (cs *CommentService) checkCommentWhetherOwner(ctx context.Context, userID, commentID string) error {
// check comment if user self
comment, exist, err := cs.commentCommonRepo.GetComment(ctx, commentID)
if err != nil {
return err
}
if !exist {
return errors.BadRequest(reason.CommentNotFound)
}
if comment.UserID != userID {
return errors.BadRequest(reason.CommentEditWithoutPermission)
}
return nil
}
func (cs *CommentService) checkIsVote(ctx context.Context, userID, commentID string) (isVote bool) {
status := cs.voteCommon.GetVoteStatus(ctx, commentID, userID)
return len(status) > 0
@ -401,7 +398,11 @@ func (cs *CommentService) GetCommentPersonalWithPage(ctx context.Context, req *s
return pager.NewPageModel(total, resp), nil
}
func (cs *CommentService) notificationQuestionComment(ctx context.Context, questionUserID, commentID, commentUserID string) {
func (cs *CommentService) notificationQuestionComment(ctx context.Context, questionUserID,
questionID, questionTitle, commentID, commentUserID, commentSummary string) {
if questionUserID == commentUserID {
return
}
msg := &schema.NotificationMsg{
ReceiverUserID: questionUserID,
TriggerUserID: commentUserID,
@ -411,9 +412,52 @@ func (cs *CommentService) notificationQuestionComment(ctx context.Context, quest
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.CommentQuestion
notice_queue.AddNotification(msg)
receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, questionUserID)
if err != nil {
log.Error(err)
return
}
if !exist {
log.Warnf("user %s not found", questionUserID)
return
}
if receiverUserInfo.NoticeStatus == schema.NoticeStatusOff || len(receiverUserInfo.EMail) == 0 {
return
}
rawData := &schema.NewCommentTemplateRawData{
QuestionTitle: questionTitle,
QuestionID: questionID,
CommentID: commentID,
CommentSummary: commentSummary,
UnsubscribeCode: encryption.MD5(receiverUserInfo.Pass),
}
commentUser, _, _ := cs.userCommon.GetUserBasicInfoByID(ctx, commentUserID)
if commentUser != nil {
rawData.CommentUserDisplayName = commentUser.DisplayName
}
codeContent := &schema.EmailCodeContent{
SourceType: schema.UnsubscribeSourceType,
Email: receiverUserInfo.EMail,
UserID: receiverUserInfo.ID,
}
title, body, err := cs.emailService.NewCommentTemplate(ctx, rawData)
if err != nil {
log.Error(err)
return
}
go cs.emailService.SendAndSaveCodeWithTime(
ctx, receiverUserInfo.EMail, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 7*24*time.Hour)
}
func (cs *CommentService) notificationAnswerComment(ctx context.Context, answerUserID, commentID, commentUserID string) {
func (cs *CommentService) notificationAnswerComment(ctx context.Context,
questionID, questionTitle, answerID, answerUserID, commentID, commentUserID, commentSummary string) {
if answerUserID == commentUserID {
return
}
msg := &schema.NotificationMsg{
ReceiverUserID: answerUserID,
TriggerUserID: commentUserID,
@ -423,6 +467,46 @@ func (cs *CommentService) notificationAnswerComment(ctx context.Context, answerU
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.CommentAnswer
notice_queue.AddNotification(msg)
receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, answerUserID)
if err != nil {
log.Error(err)
return
}
if !exist {
log.Warnf("user %s not found", answerUserID)
return
}
if receiverUserInfo.NoticeStatus == schema.NoticeStatusOff || len(receiverUserInfo.EMail) == 0 {
return
}
rawData := &schema.NewCommentTemplateRawData{
QuestionTitle: questionTitle,
QuestionID: questionID,
AnswerID: answerID,
CommentID: commentID,
CommentSummary: commentSummary,
UnsubscribeCode: encryption.MD5(receiverUserInfo.Pass),
}
commentUser, _, _ := cs.userCommon.GetUserBasicInfoByID(ctx, commentUserID)
if commentUser != nil {
rawData.CommentUserDisplayName = commentUser.DisplayName
}
codeContent := &schema.EmailCodeContent{
SourceType: schema.UnsubscribeSourceType,
Email: receiverUserInfo.EMail,
UserID: receiverUserInfo.ID,
}
title, body, err := cs.emailService.NewCommentTemplate(ctx, rawData)
if err != nil {
log.Error(err)
return
}
go cs.emailService.SendAndSaveCodeWithTime(
ctx, receiverUserInfo.EMail, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 7*24*time.Hour)
}
func (cs *CommentService) notificationCommentReply(ctx context.Context, replyUserID, commentID, commentUserID string) {

View File

@ -6,6 +6,7 @@ import (
"fmt"
"html/template"
"mime"
"time"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/entity"
@ -27,7 +28,7 @@ type EmailService struct {
// EmailRepo email repository
type EmailRepo interface {
SetCode(ctx context.Context, code, content string) error
SetCode(ctx context.Context, code, content string, duration time.Duration) error
VerifyCode(ctx context.Context, code string) (content string, err error)
}
@ -51,14 +52,18 @@ type EmailConfig struct {
SMTPPassword string `json:"smtp_password"`
SMTPAuthentication bool `json:"smtp_authentication"`
RegisterTitle string `json:"register_title"`
RegisterBody string `json:"register_body"`
PassResetTitle string `json:"pass_reset_title"`
PassResetBody string `json:"pass_reset_body"`
ChangeTitle string `json:"change_title"`
ChangeBody string `json:"change_body"`
TestTitle string `json:"test_title"`
TestBody string `json:"test_body"`
RegisterTitle string `json:"register_title"`
RegisterBody string `json:"register_body"`
PassResetTitle string `json:"pass_reset_title"`
PassResetBody string `json:"pass_reset_body"`
ChangeTitle string `json:"change_title"`
ChangeBody string `json:"change_body"`
TestTitle string `json:"test_title"`
TestBody string `json:"test_body"`
NewAnswerTitle string `json:"new_answer_title"`
NewAnswerBody string `json:"new_answer_body"`
NewCommentTitle string `json:"new_comment_title"`
NewCommentBody string `json:"new_comment_body"`
}
func (e *EmailConfig) IsSSL() bool {
@ -84,8 +89,27 @@ type TestTemplateData struct {
SiteName string
}
// SendAndSaveCode send email and save code
func (es *EmailService) SendAndSaveCode(ctx context.Context, toEmailAddr, subject, body, code, codeContent string) {
es.Send(ctx, toEmailAddr, subject, body)
err := es.emailRepo.SetCode(ctx, code, codeContent, 10*time.Minute)
if err != nil {
log.Error(err)
}
}
// SendAndSaveCodeWithTime send email and save code
func (es *EmailService) SendAndSaveCodeWithTime(
ctx context.Context, toEmailAddr, subject, body, code, codeContent string, duration time.Duration) {
es.Send(ctx, toEmailAddr, subject, body)
err := es.emailRepo.SetCode(ctx, code, codeContent, duration)
if err != nil {
log.Error(err)
}
}
// Send email send
func (es *EmailService) Send(ctx context.Context, toEmailAddr, subject, body, code, codeContent string) {
func (es *EmailService) Send(ctx context.Context, toEmailAddr, subject, body string) {
log.Infof("try to send email to %s", toEmailAddr)
ec, err := es.GetEmailConfig()
if err != nil {
@ -109,13 +133,6 @@ func (es *EmailService) Send(ctx context.Context, toEmailAddr, subject, body, co
} else {
log.Infof("send email to %s success", toEmailAddr)
}
if len(code) > 0 {
err = es.emailRepo.SetCode(ctx, code, codeContent)
if err != nil {
log.Error(err)
}
}
}
// VerifyUrlExpired email send
@ -250,41 +267,118 @@ func (es *EmailService) ChangeEmailTemplate(ctx context.Context, changeEmailUrl
return titleBuf.String(), bodyBuf.String(), nil
}
// TestTemplate send test email template parse
func (es *EmailService) TestTemplate(ctx context.Context) (title, body string, err error) {
ec, err := es.GetEmailConfig()
emailConfig, err := es.GetEmailConfig()
if err != nil {
return
}
siteinfo, err := es.GetSiteGeneral(ctx)
siteInfo, err := es.GetSiteGeneral(ctx)
if err != nil {
return
}
templateData := TestTemplateData{
SiteName: siteinfo.Name,
SiteName: siteInfo.Name,
}
titleBuf := &bytes.Buffer{}
bodyBuf := &bytes.Buffer{}
title, err = es.parseTemplateData(emailConfig.TestTitle, templateData)
if err != nil {
return "", "", fmt.Errorf("email template parse error: %s", err)
}
tmpl, err := template.New("test_title").Parse(ec.TestTitle)
body, err = es.parseTemplateData(emailConfig.TestBody, templateData)
if err != nil {
return "", "", fmt.Errorf("email test title template parse error: %s", err)
return "", "", fmt.Errorf("email template parse error: %s", err)
}
err = tmpl.Execute(titleBuf, templateData)
return title, body, nil
}
// NewAnswerTemplate new answer template
func (es *EmailService) NewAnswerTemplate(ctx context.Context, raw *schema.NewAnswerTemplateRawData) (
title, body string, err error) {
emailConfig, err := es.GetEmailConfig()
if err != nil {
return "", "", fmt.Errorf("email test body template parse error: %s", err)
return
}
tmpl, err = template.New("test_body").Parse(ec.TestBody)
siteInfo, err := es.GetSiteGeneral(ctx)
if err != nil {
return "", "", fmt.Errorf("test_body template parse error: %s", err)
return
}
err = tmpl.Execute(bodyBuf, templateData)
templateData := &schema.NewAnswerTemplateData{
SiteName: siteInfo.Name,
DisplayName: raw.AnswerUserDisplayName,
QuestionTitle: raw.QuestionTitle,
AnswerUrl: fmt.Sprintf("%s/questions/%s/%s", siteInfo.SiteUrl, raw.QuestionID, raw.AnswerID),
AnswerSummary: raw.AnswerSummary,
UnsubscribeUrl: fmt.Sprintf("%s/users/unsubscribe?code=%s", siteInfo.SiteUrl, raw.UnsubscribeCode),
}
templateData.SiteName = siteInfo.Name
title, err = es.parseTemplateData(emailConfig.NewAnswerTitle, templateData)
if err != nil {
return "", "", err
return "", "", fmt.Errorf("email template parse error: %s", err)
}
return titleBuf.String(), bodyBuf.String(), nil
body, err = es.parseTemplateData(emailConfig.NewAnswerBody, templateData)
if err != nil {
return "", "", fmt.Errorf("email template parse error: %s", err)
}
return title, body, nil
}
// NewCommentTemplate new comment template
func (es *EmailService) NewCommentTemplate(ctx context.Context, raw *schema.NewCommentTemplateRawData) (
title, body string, err error) {
emailConfig, err := es.GetEmailConfig()
if err != nil {
return
}
siteInfo, err := es.GetSiteGeneral(ctx)
if err != nil {
return
}
templateData := &schema.NewCommentTemplateData{
SiteName: siteInfo.Name,
DisplayName: raw.CommentUserDisplayName,
QuestionTitle: raw.QuestionTitle,
CommentSummary: raw.CommentSummary,
UnsubscribeUrl: fmt.Sprintf("%s/users/unsubscribe?code=%s", siteInfo.SiteUrl, raw.UnsubscribeCode),
}
if len(raw.AnswerID) > 0 {
templateData.CommentUrl = fmt.Sprintf("%s/questions/%s/%s?commentId=%s", siteInfo.SiteUrl, raw.QuestionID,
raw.AnswerID, raw.CommentID)
} else {
templateData.CommentUrl = fmt.Sprintf("%s/questions/%s?commentId=%s", siteInfo.SiteUrl,
raw.QuestionID, raw.CommentID)
}
templateData.SiteName = siteInfo.Name
title, err = es.parseTemplateData(emailConfig.NewCommentTitle, templateData)
if err != nil {
return "", "", fmt.Errorf("email template parse error: %s", err)
}
body, err = es.parseTemplateData(emailConfig.NewCommentBody, templateData)
if err != nil {
return "", "", fmt.Errorf("email template parse error: %s", err)
}
return title, body, nil
}
func (es *EmailService) parseTemplateData(templateContent string, templateData interface{}) (parsedData string, err error) {
parsedDataBuf := &bytes.Buffer{}
tmpl, err := template.New("").Parse(templateContent)
if err != nil {
return "", err
}
err = tmpl.Execute(parsedDataBuf, templateData)
if err != nil {
return "", err
}
return parsedDataBuf.String(), nil
}
func (es *EmailService) GetEmailConfig() (ec *EmailConfig, err error) {

View File

@ -73,7 +73,7 @@ func (ns *NotificationCommon) HandleNotification() {
// AddNotification
// need set
// UserID
// LoginUserID
// Type 1 inbox 2 achievement
// [inbox] Activity
// [achievement] Rank

View File

@ -6,11 +6,14 @@ import (
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/service/activity_common"
"github.com/answerdev/answer/internal/service/activity_queue"
"github.com/answerdev/answer/internal/service/config"
"github.com/answerdev/answer/internal/service/meta"
"github.com/answerdev/answer/pkg/checker"
"github.com/answerdev/answer/pkg/htmltext"
"github.com/segmentfault/pacman/errors"
@ -30,8 +33,8 @@ type QuestionRepo interface {
UpdateQuestion(ctx context.Context, question *entity.Question, Cols []string) (err error)
GetQuestion(ctx context.Context, id string) (question *entity.Question, exist bool, err error)
GetQuestionList(ctx context.Context, question *entity.Question) (questions []*entity.Question, err error)
GetQuestionPage(ctx context.Context, page, pageSize int, question *entity.Question) (questions []*entity.Question, total int64, err error)
SearchList(ctx context.Context, search *schema.QuestionSearch) ([]*entity.QuestionTag, int64, error)
GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string) (
questionList []*entity.Question, total int64, err error)
UpdateQuestionStatus(ctx context.Context, question *entity.Question) (err error)
SearchByTitleLike(ctx context.Context, title string) (questionList []*entity.Question, err error)
UpdatePvCount(ctx context.Context, questionID string) (err error)
@ -126,21 +129,15 @@ func (qs *QuestionCommon) UpdataPostSetTime(ctx context.Context, questionID stri
func (qs *QuestionCommon) FindInfoByID(ctx context.Context, questionIDs []string, loginUserID string) (map[string]*schema.QuestionInfo, error) {
list := make(map[string]*schema.QuestionInfo)
listAddTag := make([]*entity.QuestionTag, 0)
questionList, err := qs.questionRepo.FindByID(ctx, questionIDs)
if err != nil {
return list, err
}
for _, item := range questionList {
itemAddTag := &entity.QuestionTag{}
itemAddTag.Question = *item
listAddTag = append(listAddTag, itemAddTag)
}
QuestionInfo, err := qs.ListFormat(ctx, listAddTag, loginUserID)
questions, err := qs.FormatQuestions(ctx, questionList, loginUserID)
if err != nil {
return list, err
}
for _, item := range QuestionInfo {
for _, item := range questions {
list[item.ID] = item
}
return list, nil
@ -193,9 +190,15 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
showinfo.Tags = tagmap
userIds := make([]string, 0)
userIds = append(userIds, dbinfo.UserID)
userIds = append(userIds, dbinfo.LastEditUserID)
userIds = append(userIds, showinfo.LastAnsweredUserID)
if checker.IsNotZeroString(dbinfo.UserID) {
userIds = append(userIds, dbinfo.UserID)
}
if checker.IsNotZeroString(dbinfo.LastEditUserID) {
userIds = append(userIds, dbinfo.LastEditUserID)
}
if checker.IsNotZeroString(showinfo.LastAnsweredUserID) {
userIds = append(userIds, showinfo.LastAnsweredUserID)
}
userInfoMap, err := qs.userCommon.BatchUserBasicInfoByID(ctx, userIds)
if err != nil {
return showinfo, err
@ -244,13 +247,114 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
return showinfo, nil
}
func (qs *QuestionCommon) ListFormat(ctx context.Context, questionList []*entity.QuestionTag, loginUserID string) ([]*schema.QuestionInfo, error) {
func (qs *QuestionCommon) FormatQuestionsPage(
ctx context.Context, questionList []*entity.Question, loginUserID string, orderCond string) (
formattedQuestions []*schema.QuestionPageResp, err error) {
language := handler.GetLangByCtx(ctx)
askedOp := translator.GlobalTrans.Tr(language, schema.QuestionPageRespOperationTypeAsked)
answeredOp := translator.GlobalTrans.Tr(language, schema.QuestionPageRespOperationTypeAnswered)
modifiedOp := translator.GlobalTrans.Tr(language, schema.QuestionPageRespOperationTypeModified)
formattedQuestions = make([]*schema.QuestionPageResp, 0)
questionIDs := make([]string, 0)
userIDs := make([]string, 0)
for _, questionInfo := range questionList {
t := &schema.QuestionPageResp{
ID: questionInfo.ID,
Title: questionInfo.Title,
UrlTitle: htmltext.UrlTitle(questionInfo.Title),
Description: htmltext.FetchExcerpt(questionInfo.ParsedText, "...", 240),
Status: questionInfo.Status,
ViewCount: questionInfo.ViewCount,
UniqueViewCount: questionInfo.UniqueViewCount,
VoteCount: questionInfo.VoteCount,
AnswerCount: questionInfo.AnswerCount,
CollectionCount: questionInfo.CollectionCount,
FollowCount: questionInfo.FollowCount,
AcceptedAnswerID: questionInfo.AcceptedAnswerID,
LastAnswerID: questionInfo.LastAnswerID,
}
questionIDs = append(questionIDs, questionInfo.ID)
userIDs = append(userIDs, questionInfo.UserID)
haveEdited, haveAnswered := false, false
if checker.IsNotZeroString(questionInfo.LastEditUserID) {
haveEdited = true
userIDs = append(userIDs, questionInfo.LastEditUserID)
}
if checker.IsNotZeroString(questionInfo.LastAnswerID) {
haveAnswered = true
answerInfo, exist, err := qs.answerRepo.GetAnswer(ctx, questionInfo.LastAnswerID)
if err == nil && exist {
if answerInfo.LastEditUserID != "0" {
t.LastAnsweredUserID = answerInfo.LastEditUserID
} else {
t.LastAnsweredUserID = answerInfo.UserID
}
t.LastAnsweredAt = answerInfo.CreatedAt
userIDs = append(userIDs, t.LastAnsweredUserID)
}
}
// if order condition is newest or nobody edited or nobody answered, only show question author
if orderCond == schema.QuestionOrderCondNewest || (!haveEdited && !haveAnswered) {
t.OperationType = askedOp
t.OperatedAt = questionInfo.CreatedAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: questionInfo.UserID}
} else {
// if no one
if haveEdited {
t.OperationType = modifiedOp
t.OperatedAt = questionInfo.UpdatedAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: questionInfo.LastEditUserID}
}
if haveAnswered {
if t.LastAnsweredAt.Unix() > t.OperatedAt {
t.OperationType = answeredOp
t.OperatedAt = t.LastAnsweredAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: t.LastAnsweredUserID}
}
}
}
formattedQuestions = append(formattedQuestions, t)
}
tagsMap, err := qs.tagCommon.BatchGetObjectTag(ctx, questionIDs)
if err != nil {
return formattedQuestions, err
}
userInfoMap, err := qs.userCommon.BatchUserBasicInfoByID(ctx, userIDs)
if err != nil {
return formattedQuestions, err
}
for _, item := range formattedQuestions {
tags, ok := tagsMap[item.ID]
if ok {
item.Tags = tags
} else {
item.Tags = make([]*schema.TagResp, 0)
}
userInfo := userInfoMap[item.Operator.ID]
if userInfo != nil {
item.Operator.DisplayName = userInfo.DisplayName
item.Operator.Username = userInfo.Username
item.Operator.Rank = userInfo.Rank
}
}
return formattedQuestions, nil
}
func (qs *QuestionCommon) FormatQuestions(ctx context.Context, questionList []*entity.Question, loginUserID string) ([]*schema.QuestionInfo, error) {
list := make([]*schema.QuestionInfo, 0)
objectIds := make([]string, 0)
userIds := make([]string, 0)
for _, questionInfo := range questionList {
item := qs.ShowListFormat(ctx, questionInfo)
item := qs.ShowFormat(ctx, questionInfo)
list = append(list, item)
objectIds = append(objectIds, item.ID)
userIds = append(userIds, item.UserID)
@ -387,8 +491,8 @@ func (as *QuestionCommon) RemoveAnswer(ctx context.Context, id string) (err erro
return as.answerRepo.RemoveAnswer(ctx, id)
}
func (qs *QuestionCommon) ShowListFormat(ctx context.Context, data *entity.QuestionTag) *schema.QuestionInfo {
return qs.ShowFormat(ctx, &data.Question)
func (qs *QuestionCommon) ShowListFormat(ctx context.Context, data *entity.Question) *schema.QuestionInfo {
return qs.ShowFormat(ctx, data)
}
func (qs *QuestionCommon) ShowFormat(ctx context.Context, data *entity.Question) *schema.QuestionInfo {

View File

@ -476,11 +476,7 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
question.UpdatedAt = now
question.PostUpdateTime = now
question.UserID = dbinfo.UserID
question.LastEditUserID = "0"
if dbinfo.UserID != req.UserID {
question.LastEditUserID = req.UserID
}
question.LastEditUserID = req.UserID
oldTags, tagerr := qs.tagCommon.GetObjectEntityTag(ctx, question.ID)
if tagerr != nil {
@ -655,12 +651,13 @@ func (qs *QuestionService) SearchUserList(ctx context.Context, userName, order s
if !Exist {
return userlist, 0, nil
}
search := &schema.QuestionSearch{}
search.Order = order
search := &schema.QuestionPageReq{}
search.OrderCond = order
search.Page = page
search.PageSize = pageSize
search.UserID = userinfo.ID
questionlist, count, err := qs.SearchList(ctx, search, loginUserID)
search.UserIDBeSearched = userinfo.ID
search.LoginUserID = loginUserID
questionlist, count, err := qs.GetQuestionPage(ctx, search)
if err != nil {
return userlist, 0, err
}
@ -778,12 +775,13 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
if !Exist {
return userQuestionlist, userAnswerlist, nil
}
search := &schema.QuestionSearch{}
search.Order = "score"
search := &schema.QuestionPageReq{}
search.OrderCond = "score"
search.Page = 0
search.PageSize = 5
search.UserID = userinfo.ID
questionlist, _, err := qs.SearchList(ctx, search, loginUserID)
search.UserIDBeSearched = userinfo.ID
search.LoginUserID = loginUserID
questionlist, _, err := qs.GetQuestionPage(ctx, search)
if err != nil {
return userQuestionlist, userAnswerlist, err
}
@ -858,57 +856,64 @@ func (qs *QuestionService) SearchByTitleLike(ctx context.Context, title string,
}
// SimilarQuestion
func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID string, loginUserID string) ([]*schema.QuestionInfo, int64, error) {
list := make([]*schema.QuestionInfo, 0)
func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID string, loginUserID string) ([]*schema.QuestionPageResp, int64, error) {
question, err := qs.questioncommon.Info(ctx, questionID, loginUserID)
if err != nil {
return list, 0, nil
return nil, 0, nil
}
tagNames := make([]string, 0, len(question.Tags))
for _, tag := range question.Tags {
tagNames = append(tagNames, tag.SlugName)
}
search := &schema.QuestionSearch{}
search.Order = "frequent"
search := &schema.QuestionPageReq{}
search.OrderCond = "frequent"
search.Page = 0
search.PageSize = 6
if len(tagNames) > 0 {
search.Tag = tagNames[0]
}
return qs.SearchList(ctx, search, loginUserID)
search.LoginUserID = loginUserID
return qs.GetQuestionPage(ctx, search)
}
// SearchList
func (qs *QuestionService) SearchList(ctx context.Context, req *schema.QuestionSearch, loginUserID string) ([]*schema.QuestionInfo, int64, error) {
// GetQuestionPage query questions page
func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.QuestionPageReq) (
questions []*schema.QuestionPageResp, total int64, err error) {
questions = make([]*schema.QuestionPageResp, 0)
// query by tag condition
if len(req.Tag) > 0 {
tagInfo, has, err := qs.tagCommon.GetTagBySlugName(ctx, strings.ToLower(req.Tag))
tagInfo, exist, err := qs.tagCommon.GetTagBySlugName(ctx, strings.ToLower(req.Tag))
if err != nil {
log.Error("tagCommon.GetTagListByNames error", err)
return nil, 0, err
}
if has {
req.TagIDs = append(req.TagIDs, tagInfo.ID)
if exist {
req.TagID = tagInfo.ID
}
}
list := make([]*schema.QuestionInfo, 0)
if req.UserName != "" {
userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.UserName)
// query by user condition
if req.Username != "" {
userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.Username)
if err != nil {
return list, 0, err
return nil, 0, err
}
if !exist {
return list, 0, err
return questions, 0, nil
}
req.UserID = userinfo.ID
req.UserIDBeSearched = userinfo.ID
}
questionList, count, err := qs.questionRepo.SearchList(ctx, req)
questionList, total, err := qs.questionRepo.GetQuestionPage(ctx, req.Page, req.PageSize,
req.UserIDBeSearched, req.TagID, req.OrderCond)
if err != nil {
return list, count, err
return nil, 0, err
}
list, err = qs.questioncommon.ListFormat(ctx, questionList, loginUserID)
questions, err = qs.questioncommon.FormatQuestionsPage(ctx, questionList, req.LoginUserID, req.OrderCond)
if err != nil {
return list, count, err
return nil, 0, err
}
return list, count, nil
return questions, total, nil
}
func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, questionID string, setStatusStr string) error {

View File

@ -27,19 +27,19 @@ func NewSearchParser(tagCommonService *tag_common.TagCommonService, userCommon *
// but if match two type, it will return false
func (sp *SearchParser) ParseStructure(dto *schema.SearchDTO) (
searchType string,
// search all
// search all
userID string,
votes int,
// search questions
// search questions
notAccepted bool,
isQuestion bool,
views,
answers int,
// search answers
// search answers
accepted bool,
questionID string,
isAnswer bool,
// common fields
// common fields
tags,
words []string,
) {
@ -202,7 +202,7 @@ func (sp *SearchParser) parseUserID(query *string, currentUserID string) (userID
re := regexp.MustCompile(exprUserID)
res := re.FindStringSubmatch(q)
if strings.Index(q, exprMe) != -1 {
if strings.Contains(q, exprMe) {
userID = currentUserID
q = strings.ReplaceAll(q, exprMe, "")
} else if len(res) == 2 {

View File

@ -236,7 +236,7 @@ func (s *SiteInfoService) UpdateSMTPConfig(ctx context.Context, req *schema.Upda
if err != nil {
return err
}
go s.emailService.Send(ctx, req.TestEmailRecipient, title, body, "", "")
go s.emailService.SendAndSaveCode(ctx, req.TestEmailRecipient, title, body, "", "")
}
return
}

View File

@ -330,28 +330,31 @@ func (ts *TagCommonService) tagFormatRecommendAndReserved(ctx context.Context, t
// BatchGetObjectTag batch get object tag
func (ts *TagCommonService) BatchGetObjectTag(ctx context.Context, objectIds []string) (map[string][]*schema.TagResp, error) {
objectIDTagMap := make(map[string][]*schema.TagResp)
tagIDList := make([]string, 0)
tagsInfoMap := make(map[string]*entity.Tag)
tagList, err := ts.tagRelRepo.BatchGetObjectTagRelList(ctx, objectIds)
objectTagRelList, err := ts.tagRelRepo.BatchGetObjectTagRelList(ctx, objectIds)
if err != nil {
return objectIDTagMap, err
}
for _, tag := range tagList {
tagIDList := make([]string, 0)
for _, tag := range objectTagRelList {
tagIDList = append(tagIDList, tag.TagID)
}
tagsInfoList, err := ts.GetTagListByIDs(ctx, tagIDList)
if err != nil {
return objectIDTagMap, err
}
for _, item := range tagsInfoList {
tagsInfoMap[item.ID] = item
tagsInfoMapping := make(map[string]*entity.Tag)
tagsRank := make(map[string]int) // Used for sorting
for idx, item := range tagsInfoList {
tagsInfoMapping[item.ID] = item
tagsRank[item.ID] = idx
}
for _, item := range tagList {
_, ok := tagsInfoMap[item.TagID]
for _, item := range objectTagRelList {
_, ok := tagsInfoMapping[item.TagID]
if ok {
tagInfo := tagsInfoMap[item.TagID]
tagInfo := tagsInfoMapping[item.TagID]
t := &schema.TagResp{
ID: tagInfo.ID,
SlugName: tagInfo.SlugName,
DisplayName: tagInfo.DisplayName,
MainTagSlugName: tagInfo.MainTagSlugName,
@ -361,12 +364,10 @@ func (ts *TagCommonService) BatchGetObjectTag(ctx context.Context, objectIds []s
objectIDTagMap[item.ObjectID] = append(objectIDTagMap[item.ObjectID], t)
}
}
for _, taglist := range objectIDTagMap {
sort.SliceStable(taglist, func(i, j int) bool {
return taglist[i].Reserved
})
sort.SliceStable(taglist, func(i, j int) bool {
return taglist[i].Recommend
// The sorting in tagsRank is correct, object tags should be sorted by tagsRank
for _, objectTags := range objectIDTagMap {
sort.SliceStable(objectTags, func(i, j int) bool {
return tagsRank[objectTags[i].ID] < tagsRank[objectTags[j].ID]
})
}
return objectIDTagMap, nil

View File

@ -126,6 +126,10 @@ func (us *UserCommon) MakeUsername(ctx context.Context, displayName string) (use
return "", errors.BadRequest(reason.UsernameInvalid)
}
if checker.IsReservedUsername(username) {
return "", errors.BadRequest(reason.UsernameInvalid)
}
for {
_, has, err := us.userRepo.GetByUsername(ctx, username+suffix)
if err != nil {

View File

@ -20,6 +20,7 @@ import (
"github.com/answerdev/answer/internal/service/service_config"
"github.com/answerdev/answer/internal/service/siteinfo_common"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/checker"
"github.com/google/uuid"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
@ -168,7 +169,7 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
if err != nil {
return "", err
}
go us.emailService.Send(ctx, req.Email, title, body, code, data.ToJSONString())
go us.emailService.SendAndSaveCode(ctx, req.Email, title, body, code, data.ToJSONString())
return code, nil
}
@ -240,20 +241,31 @@ func (us *UserService) UserModifyPassword(ctx context.Context, request *schema.U
}
// UpdateInfo update user info
func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) (err error) {
func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) (
errFields []*validator.FormErrorField, err error) {
if len(req.Username) > 0 {
userInfo, exist, err := us.userRepo.GetByUsername(ctx, req.Username)
if err != nil {
return err
return nil, err
}
if exist && userInfo.ID != req.UserID {
return errors.BadRequest(reason.UsernameDuplicate)
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: reason.UsernameDuplicate,
})
return errFields, errors.BadRequest(reason.UsernameDuplicate)
}
if checker.IsReservedUsername(req.Username) {
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: reason.UsernameInvalid,
})
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
}
avatar, err := json.Marshal(req.Avatar)
if err != nil {
err = errors.BadRequest(reason.UserSetAvatar).WithError(err).WithStack()
return err
return nil, errors.BadRequest(reason.UserSetAvatar).WithError(err).WithStack()
}
userInfo := entity.User{}
userInfo.ID = req.UserID
@ -264,10 +276,8 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
userInfo.Location = req.Location
userInfo.Website = req.Website
userInfo.Username = req.Username
if err := us.userRepo.UpdateInfo(ctx, &userInfo); err != nil {
return err
}
return nil
err = us.userRepo.UpdateInfo(ctx, &userInfo)
return nil, err
}
func (us *UserService) UserEmailHas(ctx context.Context, email string) (bool, error) {
@ -292,14 +302,18 @@ func (us *UserService) UserUpdateInterface(ctx context.Context, req *schema.Upda
// UserRegisterByEmail user register
func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegisterReq) (
resp *schema.GetUserResp, err error,
resp *schema.GetUserResp, errFields []*validator.FormErrorField, err error,
) {
_, has, err := us.userRepo.GetByEmail(ctx, registerUserInfo.Email)
if err != nil {
return nil, err
return nil, nil, err
}
if has {
return nil, errors.BadRequest(reason.EmailDuplicate)
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "e_mail",
ErrorMsg: reason.EmailDuplicate,
})
return nil, errFields, errors.BadRequest(reason.EmailDuplicate)
}
userInfo := &entity.User{}
@ -307,11 +321,15 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
userInfo.DisplayName = registerUserInfo.Name
userInfo.Pass, err = us.encryptPassword(ctx, registerUserInfo.Pass)
if err != nil {
return nil, err
return nil, nil, err
}
userInfo.Username, err = us.userCommonService.MakeUsername(ctx, registerUserInfo.Name)
if err != nil {
return nil, err
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "name",
ErrorMsg: reason.UsernameInvalid,
})
return nil, errFields, err
}
userInfo.IPInfo = registerUserInfo.IP
userInfo.MailStatus = entity.EmailStatusToBeVerified
@ -319,7 +337,7 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
userInfo.LastLoginDate = time.Now()
err = us.userRepo.AddUser(ctx, userInfo)
if err != nil {
return nil, err
return nil, nil, err
}
// send email
@ -331,9 +349,9 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
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 nil, err
return nil, nil, err
}
go us.emailService.Send(ctx, userInfo.EMail, title, body, code, data.ToJSONString())
go us.emailService.SendAndSaveCode(ctx, userInfo.EMail, title, body, code, data.ToJSONString())
roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID)
if err != nil {
@ -351,16 +369,16 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
}
resp.AccessToken, err = us.authService.SetUserCacheInfo(ctx, userCacheInfo)
if err != nil {
return nil, err
return nil, nil, err
}
resp.IsAdmin = userCacheInfo.IsAdmin
if resp.IsAdmin {
err = us.authService.SetAdminUserCacheInfo(ctx, resp.AccessToken, &entity.UserCacheInfo{UserID: userInfo.ID})
if err != nil {
return nil, err
return nil, nil, err
}
}
return resp, nil
return resp, nil, nil
}
func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) error {
@ -382,7 +400,7 @@ func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) e
if err != nil {
return err
}
go us.emailService.Send(ctx, userInfo.EMail, title, body, code, data.ToJSONString())
go us.emailService.SendAndSaveCode(ctx, userInfo.EMail, title, body, code, data.ToJSONString())
return nil
}
@ -514,7 +532,7 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
}
log.Infof("send email confirmation %s", verifyEmailURL)
go us.emailService.Send(context.Background(), req.Email, title, body, code, data.ToJSONString())
go us.emailService.SendAndSaveCode(context.Background(), req.Email, title, body, code, data.ToJSONString())
return nil, nil
}
@ -598,6 +616,25 @@ func (us *UserService) UserRanking(ctx context.Context) (resp *schema.UserRankin
return us.warpStatRankingResp(userInfoMapping, rankStat, voteStat, userRoleRels), nil
}
// UserUnsubscribeEmailNotification user unsubscribe email notification
func (us *UserService) UserUnsubscribeEmailNotification(
ctx context.Context, req *schema.UserUnsubscribeEmailNotificationReq) (err error) {
data := &schema.EmailCodeContent{}
err = data.FromJSONString(req.Content)
if err != nil || len(data.UserID) == 0 {
return errors.BadRequest(reason.EmailVerifyURLExpired)
}
userInfo, exist, err := us.userRepo.GetByUserID(ctx, data.UserID)
if err != nil {
return err
}
if !exist {
return errors.BadRequest(reason.UserNotFound)
}
return us.userRepo.UpdateNoticeStatus(ctx, userInfo.ID, schema.NoticeStatusOff)
}
func (us *UserService) getActivityUserRankStat(ctx context.Context, startTime, endTime time.Time, limit int,
userIDExist map[string]bool) (rankStat []*entity.ActivityUserRankStat, userIDs []string, err error) {
rankStat, err = us.activityRepo.GetUsersWhoHasGainedTheMostReputation(ctx, startTime, endTime, limit)

View File

@ -0,0 +1,39 @@
package checker
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/answerdev/answer/configs"
"github.com/answerdev/answer/internal/cli"
"github.com/answerdev/answer/pkg/dir"
)
var (
reservedUsernameMapping = make(map[string]bool)
reservedUsernameInit sync.Once
)
func initReservedUsername() {
reservedUsernamesJsonFilePath := filepath.Join(cli.ConfigFileDir, cli.DefaultReservedUsernamesConfigFileName)
if dir.CheckFileExist(reservedUsernamesJsonFilePath) {
// if reserved username file exists, read it and replace configuration
reservedUsernamesJsonFile, err := os.ReadFile(reservedUsernamesJsonFilePath)
if err == nil {
configs.ReservedUsernames = reservedUsernamesJsonFile
}
}
var usernames []string
_ = json.Unmarshal(configs.ReservedUsernames, &usernames)
for _, username := range usernames {
reservedUsernameMapping[username] = true
}
}
// IsReservedUsername checks whether the username is reserved
func IsReservedUsername(username string) bool {
reservedUsernameInit.Do(initReservedUsername)
return reservedUsernameMapping[username]
}

View File

@ -0,0 +1,6 @@
package checker
// IsNotZeroString check s is not empty string and is not "0"
func IsNotZeroString(s string) bool {
return len(s) > 0 && s != "0"
}

13
pkg/encryption/md5.go Normal file
View File

@ -0,0 +1,13 @@
package encryption
import (
"crypto/md5"
"encoding/hex"
)
// MD5 return md5 hash
func MD5(data string) string {
h := md5.New()
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}

View File

@ -1,7 +1,7 @@
package htmltext
import (
"io/ioutil"
"io"
"net/http"
"net/url"
"regexp"
@ -75,11 +75,10 @@ func FetchExcerpt(html, trimMarker string, limit int) (text string) {
runeText := []rune(text)
if len(runeText) <= limit {
text = string(runeText)
} else {
text = string(runeText[0:limit])
return
}
text += trimMarker
text = string(runeText[0:limit]) + trimMarker
return
}
@ -89,7 +88,7 @@ func GetPicByUrl(Url string) string {
return ""
}
defer res.Body.Close()
pix, err := ioutil.ReadAll(res.Body)
pix, err := io.ReadAll(res.Body)
if err != nil {
return ""
}

View File

@ -50,6 +50,10 @@ func TestFetchExcerpt(t *testing.T) {
expected = "hello你好😂..."
text = FetchExcerpt("<p>hello你好😂world</p>", "...", 8)
assert.Equal(t, expected, text)
expected = "hello你好"
text = FetchExcerpt("<p>hello你好</p>", "...", 8)
assert.Equal(t, expected, text)
}
func TestUrlTitle(t *testing.T) {

View File

@ -1 +1 @@
<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><script defer="defer" src="/static/js/main.554b9f62.js"></script><link href="/static/css/main.401dc3ca.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"><div id="spin-mask" hidden><script>try{document.querySelector("#spin-mask").removeAttribute("hidden")}catch(e){}</script><style>@keyframes _doc-spin{to{transform:rotate(360deg)}}#spin-mask{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#fff;z-index:9999}#spin-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#spin-container .spinner{box-sizing:border-box;display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25rem solid currentColor;border-right-color:transparent;color:rgba(108,117,125,.75);border-radius:50%;animation:.75s linear infinite _doc-spin}</style><div id="spin-container"><div class="spinner"></div></div></div></div></body></html>
<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><script defer="defer" src="/static/js/main.fde484b3.js"></script><link href="/static/css/main.401dc3ca.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"><div id="spin-mask"><noscript><style>#spin-mask{display:none!important}</style></noscript><style>@keyframes _doc-spin{to{transform:rotate(360deg)}}#spin-mask{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#fff;z-index:9999}#spin-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#spin-container .spinner{box-sizing:border-box;display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25rem solid currentColor;border-right-color:transparent;color:rgba(108,117,125,.75);border-radius:50%;animation:.75s linear infinite _doc-spin}</style><div id="spin-container"><div class="spinner"></div></div></div></div></body></html>

File diff suppressed because it is too large Load Diff

View File

@ -258,7 +258,7 @@ const Comment = ({ objectId, mode, commentId }) => {
)}
<div
className="fmt fs-14"
className="fmt fs-14 text-break text-wrap"
dangerouslySetInnerHTML={{ __html: item.parsed_text }}
/>
</div>

View File

@ -93,16 +93,18 @@ const Index: FC<Props> = ({
className={classnames(
'fs-14 font-monospace',
newData.original_text && 'mb-4',
)}>
{`/tags/${
newData?.main_tag_slug_name
? diffText(
newData.main_tag_slug_name,
oldData?.main_tag_slug_name,
)
: diffText(newData.slug_name, oldData?.slug_name)
}`}
</div>
)}
dangerouslySetInnerHTML={{
__html: `/tags/${
newData?.main_tag_slug_name
? diffText(
newData.main_tag_slug_name,
oldData?.main_tag_slug_name,
)
: diffText(newData.slug_name, oldData?.slug_name)
}`,
}}
/>
)}
<div
dangerouslySetInnerHTML={{

View File

@ -1,7 +1,7 @@
import { FC, memo } from 'react';
import { Nav, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import type * as Type from '@/common/interface';
import { Avatar, Icon } from '@/components';
@ -14,6 +14,13 @@ interface Props {
const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleLinkClick = (evt) => {
evt.preventDefault();
const { href } = evt.currentTarget;
const { pathname } = new URL(href);
navigate(pathname);
};
return (
<>
<Nav className="flex-row">
@ -26,7 +33,7 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
</Nav.Link>
<Nav.Link
as={Link}
as={NavLink}
to="/users/notifications/achievement"
className="icon-link d-flex align-items-center justify-content-center p-0 me-3 position-relative">
<Icon name="trophy-fill" className="fs-4" />
@ -46,17 +53,26 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item href={`/users/${userInfo.username}`}>
<Dropdown.Item
href={`/users/${userInfo.username}`}
onClick={handleLinkClick}>
{t('header.nav.profile')}
</Dropdown.Item>
<Dropdown.Item href="/users/settings/profile">
<Dropdown.Item
href="/users/settings/profile"
onClick={handleLinkClick}>
{t('header.nav.setting')}
</Dropdown.Item>
{userInfo?.is_admin ? (
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
<Dropdown.Item href="/admin" onClick={handleLinkClick}>
{t('header.nav.admin')}
</Dropdown.Item>
) : null}
{redDot?.can_revision ? (
<Dropdown.Item href="/review" className="position-relative">
<Dropdown.Item
href="/review"
className="position-relative"
onClick={handleLinkClick}>
{t('header.nav.review')}
{redDot?.revision > 0 && (
<span className="position-absolute top-50 translate-middle-y end-0 me-3 p-2 bg-danger border border-light rounded-circle">

View File

@ -49,6 +49,14 @@ const Header: FC = () => {
const handleInput = (val) => {
setSearch(val);
};
const handleSearch = (evt) => {
evt.preventDefault();
if (!searchStr) {
return;
}
const searchUrl = `/search?q=${encodeURIComponent(searchStr)}`;
navigate(searchUrl);
};
const handleLogout = async () => {
await logout();
@ -118,7 +126,13 @@ const Header: FC = () => {
<NavItems redDot={redDot} userInfo={user} logOut={handleLogout} />
) : (
<>
<Button variant="link" className="me-2" href="/users/login">
<Button
variant="link"
className={classnames('me-2', {
'link-light': navbarStyle === 'theme-colored',
'link-primary': navbarStyle !== 'theme-colored',
})}
href="/users/login">
{t('btns.login')}
</Button>
{loginSetting.allow_new_registrations && (
@ -153,7 +167,10 @@ const Header: FC = () => {
<hr className="hr lg-none mt-2" />
<Col lg={4} className="d-flex justify-content-center">
<Form action="/search" className="w-75 px-0 px-lg-2">
<Form
action="/search"
className="w-75 px-0 px-lg-2"
onSubmit={handleSearch}>
<FormControl
placeholder={t('header.search.placeholder')}
className="placeholder-search"

View File

@ -28,60 +28,6 @@ interface Props {
source: 'questions' | 'tag';
}
const QuestionLastUpdate = ({ q }) => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
if (q.update_time > q.edit_time) {
// question answered
return (
<div className="d-flex">
<BaseUserCard
data={q.last_answered_user_info}
showAvatar={false}
className="me-1"
/>
<FormatTime
time={q.update_time}
className="text-secondary ms-1"
preFix={t('answered')}
/>
</div>
);
}
if (q.edit_time > q.update_time) {
// question modified
return (
<div className="d-flex">
<BaseUserCard
data={q.update_user_info}
showAvatar={false}
className="me-1"
/>
<FormatTime
time={q.edit_time}
className="text-secondary ms-1"
preFix={t('modified')}
/>
</div>
);
}
// default: asked
return (
<div className="d-flex">
<BaseUserCard data={q.user_info} showAvatar={false} className="me-1" />
<FormatTime
time={q.create_time}
preFix={t('asked')}
className="text-secondary ms-1"
/>
</div>
);
};
const QuestionList: FC<Props> = ({ source }) => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const { tagName = '' } = useParams();
@ -132,7 +78,19 @@ const QuestionList: FC<Props> = ({ source }) => {
</NavLink>
</h5>
<div className="d-flex flex-column flex-md-row align-items-md-center fs-14 mb-2 text-secondary">
<QuestionLastUpdate q={li} />
<div className="d-flex">
<BaseUserCard
data={li.operator}
showAvatar={false}
className="me-1"
/>
<FormatTime
time={li.operated_at}
className="text-secondary ms-1"
preFix={t(li.operation_type)}
/>
</div>
<div className="ms-0 ms-md-3 mt-2 mt-md-0">
<span>
<Icon name="hand-thumbs-up-fill" />

View File

@ -1,4 +1,5 @@
import React, { memo, FC } from 'react';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
@ -21,8 +22,8 @@ const Index: FC<IProps> = ({
href ||= pathFactory.tagLanding(data?.slug_name);
return (
<a
href={href}
<Link
to={href}
className={classNames(
'badge-tag rounded-1',
data.reserved && 'badge-tag-reserved',
@ -30,7 +31,7 @@ const Index: FC<IProps> = ({
className,
)}>
<span className={textClassName}>{data.slug_name}</span>
</a>
</Link>
);
};

View File

@ -253,7 +253,7 @@ const TagSelector: FC<IProps> = ({
variant="link"
className="px-3 btn-no-border w-100 text-start"
onClick={() => {
tagModal.onShow();
tagModal.onShow(tag);
}}>
+ {t('create_btn')}
</Button>

View File

@ -39,8 +39,21 @@ const useTagModal = (props: IProps = {}) => {
setVisibleState(false);
};
const onShow = () => {
const onShow = (searchStr = '') => {
setVisibleState(true);
setFormData({
...formData,
displayName: {
value: searchStr,
isInvalid: false,
errorMsg: '',
},
slugName: {
value: searchStr,
isInvalid: false,
errorMsg: '',
},
});
};
const checkValidated = (): boolean => {

View File

@ -16,7 +16,7 @@ const AnswerLinks = () => {
</Col>
<Col xs={6}>
<a href="https://meta.answer.dev" target="_blank" rel="noreferrer">
{t('feedback')}
{t('support')}
</a>
</Col>
</Row>

View File

@ -69,7 +69,7 @@ const Index: FC<Props> = ({
<div id={data.id} ref={answerRef} className="answer-item py-4">
<article
dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt"
className="fmt text-break text-wrap"
/>
<div className="d-flex align-items-center mt-4">
<Actions

View File

@ -26,6 +26,7 @@ const Index: FC<Props> = ({ data }) => {
answerId: data.object.id,
});
}
return (
<ListGroupItem className="py-3 px-0 border-start-0 border-end-0 bg-transparent">
<div className="mb-2 clearfix">

View File

@ -31,13 +31,15 @@ const Index = () => {
const getProfile = () => {
getLoggedUserInfo().then((res) => {
setFormData({
notice_switch: {
value: res.notice_status === 1,
isInvalid: false,
errorMsg: '',
},
});
if (res) {
setFormData({
notice_switch: {
value: res.notice_status === 1,
isInvalid: false,
errorMsg: '',
},
});
}
});
};

View File

@ -43,12 +43,14 @@ const Index: React.FC = () => {
});
const getProfile = () => {
getLoggedUserInfo().then((res) => {
formData.display_name.value = res.display_name;
formData.bio.value = res.bio;
formData.avatar.value = res.avatar;
formData.location.value = res.location;
formData.website.value = res.website;
setFormData({ ...formData });
if (res) {
formData.display_name.value = res.display_name;
formData.bio.value = res.bio;
formData.avatar.value = res.avatar;
formData.location.value = res.location;
formData.website.value = res.website;
setFormData({ ...formData });
}
});
};

View File

@ -0,0 +1,36 @@
import { FC, memo, useEffect } from 'react';
import { Container, Row, Col } from 'react-bootstrap';
import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unsubscribe } from '@/services';
import { usePageTags } from '@/hooks';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'unsubscribe' });
usePageTags({
title: t('page_title'),
});
const [searchParams] = useSearchParams();
const code = searchParams.get('code');
useEffect(() => {
if (code) {
unsubscribe(code);
}
}, [code]);
return (
<Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center">
<Col lg={6}>
<h3 className="text-center mt-3 mb-5">{t('success_title')}</h3>
<p className="text-center">{t('success_desc')}</p>
<div className="text-center">
<Link to="/users/settings/notify">{t('link')}</Link>
</div>
</Col>
</Row>
</Container>
);
};
export default memo(Index);

View File

@ -337,6 +337,10 @@ const routes: RouteNode[] = [
},
],
},
{
path: '/users/unsubscribe',
page: 'pages/Users/Unsubscribe',
},
],
},
{

View File

@ -20,7 +20,7 @@ export const useQuestionList = (params: Type.QueryQuestionsReq) => {
export const useHotQuestions = (
params: Type.QueryQuestionsReq = {
page: 1,
page_size: 10,
page_size: 6,
order: 'frequent',
},
) => {

View File

@ -257,3 +257,8 @@ export const getAppSettings = () => {
export const reopenQuestion = (params: { question_id: string }) => {
return request.put('/answer/api/v1/question/reopen', params);
};
export const unsubscribe = (code: string) => {
const apiUrl = '/answer/api/v1/user/email/notification';
return request.put(apiUrl, { code });
};

View File

@ -81,20 +81,13 @@ function formatUptime(value) {
return `< 1 ${t('dates.hour')}`;
}
function escapeRemove(str) {
function escapeRemove(str: string) {
if (!str || typeof str !== 'string') return str;
const arrEntities = {
lt: '<',
gt: '>',
nbsp: ' ',
amp: '&',
quot: '"',
'#39': "'",
};
return str.replace(/&(lt|gt|nbsp|amp|quot|#39);/gi, function (all, t) {
return arrEntities[t];
});
let temp: HTMLDivElement | null = document.createElement('div');
temp.innerHTML = str;
const output = temp?.innerText || temp.textContent;
temp = null;
return output;
}
function mixColor(color_1, color_2, weight) {
function d2h(d) {
@ -179,6 +172,8 @@ function diffText(newText: string, oldText: string): string {
?.replace(/<input/gi, '&lt;input');
}
const diff = Diff.diffChars(oldText, newText);
console.log(diff);
const result = diff.map((part) => {
if (part.added) {
if (part.value.replace(/\n/g, '').length <= 0) {

View File

@ -8,12 +8,23 @@ const differentCurrent = (target: string, base?: string) => {
return targetUrl.toString() !== window.location.href;
};
const storageLoginRedirect = () => {
const { pathname } = window.location;
if (pathname !== RouteAlias.login && pathname !== RouteAlias.register) {
const loc = window.location;
const redirectUrl = loc.href.replace(loc.origin, '');
Storage.set(REDIRECT_PATH_STORAGE_KEY, redirectUrl);
}
};
/**
* only navigate if not same as current url
* @param pathname
* @param callback
*/
const navigate = (pathname: string, callback: Function) => {
if (pathname === RouteAlias.login) {
storageLoginRedirect();
}
if (differentCurrent(pathname)) {
callback();
}
@ -23,12 +34,7 @@ const navigate = (pathname: string, callback: Function) => {
* auto navigate to login page with redirect info
*/
const navigateToLogin = () => {
const { pathname } = window.location;
if (pathname !== RouteAlias.login && pathname !== RouteAlias.register) {
const loc = window.location;
const redirectUrl = loc.href.replace(loc.origin, '');
Storage.set(REDIRECT_PATH_STORAGE_KEY, redirectUrl);
}
storageLoginRedirect();
navigate(RouteAlias.login, () => {
window.location.replace(RouteAlias.login);
});

View File

@ -25,31 +25,21 @@
>
<div class="d-flex">
<div class="text-secondary me-1">
<a href="/users/{{.UserInfo.Username}}"
<a href="/users/{{.Operator.Username}}"
><span class="me-1 text-break"
>{{.UserInfo.DisplayName}}</span
>{{.Operator.DisplayName}}</span
></a
><span class="fw-bold" title="Reputation"
>{{.UserInfo.Rank}}</span
>{{.Operator.Rank}}</span
>
</div>
• {{if eq .CreateTime .UpdateTime}}
<time
class="text-secondary ms-1"
datetime="{{timeFormatISO $.timezone .CreateTime}}"
title="{{translatorTimeFormatLongDate $.language $.timezone .CreateTime}}"
datetime="{{timeFormatISO $.timezone .OperatedAt}}"
title="{{translatorTimeFormatLongDate $.language $.timezone .OperatedAt}}"
>{{translator $.language "ui.question.asked"}}
{{translatorTimeFormat $.language $.timezone .CreateTime}}
{{translatorTimeFormat $.language $.timezone .OperatedAt}}
</time>
{{else if gt .UpdateTime 0}}
<time
class="text-secondary ms-1"
datetime="{{timeFormatISO $.timezone .UpdateTime}}"
title="{{translatorTimeFormatLongDate $.language $.timezone .UpdateTime}}"
>{{translator $.language "ui.question.modified"}}
{{translatorTimeFormat $.language $.timezone .UpdateTime}}
</time>
{{end}}
</div>
<div class="ms-0 ms-md-3 mt-2 mt-md-0">
<span

View File

@ -34,21 +34,21 @@
>
<div class="d-flex">
<div class="text-secondary me-1">
<a href="/users/{{.UserInfo.Username}}"
<a href="/users/{{.Operator.Username}}"
><span class="me-1 text-break"
>{{.UserInfo.DisplayName}}</span
>{{.Operator.DisplayName}}</span
></a
><span class="fw-bold" title="Reputation"
>{{.UserInfo.Rank}}</span
>{{.Operator.Rank}}</span
>
</div>
<time
class="text-secondary ms-1"
datetime="{{timeFormatISO $.timezone .CreateTime}}"
title="{{translatorTimeFormatLongDate $.language $.timezone .CreateTime}}"
datetime="{{timeFormatISO $.timezone .OperatedAt}}"
title="{{translatorTimeFormatLongDate $.language $.timezone .OperatedAt}}"
>{{translator $.language "ui.question.asked"}}
{{translatorTimeFormat $.language $.timezone .CreateTime}}
{{translatorTimeFormat $.language $.timezone .OperatedAt}}
</time>
</div>
<div class="ms-0 ms-md-3 mt-2 mt-md-0">