Compare commits

...

28 Commits

Author SHA1 Message Date
LinkinStars 65f0060888 Merge remote-tracking branch 'origin/fix/2.0.3/user' into test
Lint / Lint (ubuntu-latest) (push) Has been cancelled
Build Docker Image For Test / Build and Push (push) Has been cancelled
2026-08-17 14:25:19 +08:00
Duansg 2c0ced322d fix: use TagStatusDeleted instead of QuestionStatusDeleted in tag permission
Lint / Lint (ubuntu-latest) (push) Has been cancelled
2026-08-17 12:23:24 +08:00
LinkinStars a203e7f608 chore: satisfy static checks 2026-08-17 11:32:39 +08:00
LinkinStars 63a67542f4 fix: constrain MCP content access 2026-08-17 10:49:54 +08:00
LinkinStars 2e2c3019a2 fix: enforce question access controls 2026-08-17 10:28:49 +08:00
LinkinStars e8ded23a4a fix: strengthen request safeguards 2026-08-14 19:17:10 +08:00
LinkinStars 21e0714a08 fix: align question operation permissions 2026-08-14 17:03:23 +08:00
Nikita Ermilov 413bbd0c7f i18n: complete Russian (ru_RU) translation 2026-08-14 17:00:03 +08:00
LinkinStars 32b451ce87 fix: normalize imported content 2026-08-14 16:48:53 +08:00
LinkinStars b80ad0a892 fix: avoid sensitive auth logging 2026-08-14 16:34:33 +08:00
LinkinStars 1ccb4b7adc fix: add ASF header to tag search test
Signed-off-by: LinkinStars <linkinstar@foxmail.com>
2026-08-11 19:50:54 +08:00
ferhat elmas da622a4927 test(converter): pin renderLinkIsUrl behavior
Add a table-driven test covering the markdown link destination check
before replacing the govalidator dependency with stdlib logic.

Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
2026-08-11 19:34:58 +08:00
Max Engine 4488ccc689 fix: tag search never matches on slug name
The search term was formatted into LOWER(%s) and passed as the *value* of the
LIKE, so the function name ended up inside the pattern:

    slug_name LIKE '%LOWER(coco)%'

That can never match. Only the display_name clause did any work, and LIKE is
case-sensitive on Postgres, so searching a tag by the name it is written in
returns nothing:

    slug_name=Coco  -> matches
    slug_name=coco  -> no match

Tags are lower case by convention, so lower case is what users type, and the
filter appears to report that no such tag exists.

Lower both sides instead. The term normalisation is extracted so it can be
covered by a test without a database.
2026-08-11 19:31:39 +08:00
ferhat elmas 98329f3b05 fix: advanced site settings setup from migration 30
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
2026-07-29 19:09:48 +08:00
LinkinStars ecef4f11dc Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	docs/release/LICENSE
#	internal/service/notification/new_question_notification_test.go
2026-07-29 17:19:20 +08:00
LinkinStars 9df5853942 fix(tests): update goroutine handling in new question email worker tests 2026-07-07 19:32:24 +08:00
LinkinStars c17e0c94c8 fix(notification): remove buffer size parameter from new question email worker for test 2026-07-07 19:03:27 +08:00
Artur Iusupov b70dda997a fix(notification): make new question email queue configurable 2026-07-06 22:31:16 +08:00
Artur Iusupov d10e6aad70 fix(notification): move new question email throttling to worker 2026-07-06 22:31:16 +08:00
Artur Iusupov e1d58ab635 feat(notification): add interval for new question emails 2026-07-06 22:31:16 +08:00
Artur Iusupov 3b6f981b81 fix(site): require explicit email verification setting
Replace OptionalBool with an explicit require_email_verification value for the login settings save request while keeping legacy read defaults intact.

Use positive RequireEmailVerification naming through the registration flow and inline the site setting mapping.

Add validation, save-path, and registration coverage for the explicit email verification setting.
2026-06-15 14:44:34 +08:00
Artur Iusupov d93e31e92a feat(site): allow disabling email verification 2026-06-15 14:44:34 +08:00
hgaol 43a91313d8 fix: accept answer fails when short links enabled (#1541)
The ownership check added to AcceptAnswer compared the answer's QuestionID
against the request's QuestionID directly. When short links are enabled,
answerRepo.GetByID re-encodes QuestionID to its short form while the
controller de-shorts req.QuestionID to its long form, so the two encodings
of the same question never matched and every accept returned "Answer do not
found". Normalize both ids via uid.DeShortID before comparing, preserving the
privilege-escalation guard for answers that truly belong to another question.
2026-06-12 16:10:22 +08:00
hhc7 682811f769 fix: scope JSON 500 to API routes, skip rewriting already-flushed responses 2026-06-05 11:26:55 +08:00
hhc7 cece87f9dc feat: add recovery middleware to handle panic gracefully 2026-06-05 11:26:55 +08:00
Ahmed Qasid e884bb61cb fix: avoid topic fallback for non-Latin titles via pragmatic ASCII transliteration (#1526)
# fix: avoid `topic` fallback for non-Latin titles via pragmatic ASCII
transliteration

> **Scope update (in response to review):** this PR is intentionally
broader than its original "Arabic-only" framing. The implementation
changes URL slug generation for **every non-Latin, non-CJK script** that
`slugify` previously stripped — see *Scope* below for the explicit list.
The goal is *not* linguistically correct romanization; it is "avoid
collapsing to `/topic` by producing a usable ASCII slug."

## What this PR is (and isn't)

**Goal:** when a question title contains characters outside Basic Latin
/ Latin Extended / CJK Han, generate a URL slug that is a deterministic
ASCII approximation instead of letting `slugify` strip everything and
falling back to the literal `"topic"`.

**Non-goal:** this is *not* a linguistically correct multi-language
romanizer. The output is a machine-acceptable ASCII slug, not what a
native speaker would choose. For example, `こんにちは` → `konnichiha` (not
the more natural `kon'nichiwa`), `ไทย` → `aithy` (not `thai`). Treat the
slug as an opaque, stable, indexable identifier — the
path-after-`/questions/<id>/` is for SEO and shareability, the canonical
reference is always the ID.

## The bug

Pure non-Latin titles previously got stripped by `slugify.Slugify`, hit
the empty-result fallback in `htmltext.UrlTitle`, and collapsed to the
literal slug `"topic"`. On a live multilingual site, every Arabic / Thai
/ Japanese-hiragana / Korean / Hebrew / Cyrillic question ended up at
`/questions/<id>/topic`.

## The fix

`UrlTitle()` gets a `convertNonLatin` pre-step that mirrors the existing
`convertChinese` pre-step pattern, using
`github.com/mozillazg/go-unidecode` (same author as `go-pinyin` already
in the repo, to minimise new-dep friction).

```
UrlTitle(title)
  → convertChinese(title)        // pre-existing: Han-block → pinyin
  → convertNonLatin(title)       // NEW: detect non-Latin letters → unidecode to ASCII
  → clearEmoji / slugify / url.QueryEscape / cutLongTitle (unchanged)
```

The non-Latin detector skips ASCII, Latin-1 Supplement, Latin
Extended-A/B, and CJK Han. Inputs that hit none of those non-Latin
letter categories short-circuit and return unchanged, so Latin-only and
Chinese-only inputs remain byte-identical (pinned by tests).

## Scope — what scripts are affected

This PR changes behavior for **any** title containing letters in scripts
that `slugify` doesn't handle. Confirmed by tests in
`pkg/htmltext/htmltext_test.go`:

| Script | Example title | Before | After |
| --- | --- | --- | --- |
| Arabic | `كيف حالك` | `topic` | `kyf-hlk` |
| Mixed Latin + Arabic | `مرحبا hello` | `hello` | `mrhb-hello` |
| Thai | `ไทย ไทย` | `topic` | `aithy-aithy` |
| Japanese hiragana | `こんにちは` | `topic` | `konnichiha` |
| Korean | `안녕하세요` | `topic` | `annyeonghaseyo` |
| Hebrew | `שלום עולם` | `topic` | `shlvm-vlm` |
| Cyrillic | `Привет мир` | `topic` | `privet-mir` |

**Unchanged:**

| Case | Behavior |
| --- | --- |
| Pure Latin (`hello world`) | unchanged → `hello-world` |
| Pure Chinese (`这是一个,标题,title`) | unchanged → `zhe-shi-yi-ge-biao-ti`
(pinyin path) |
| Japanese with Han-block kanji (`日本`) | unchanged → `ri-ben` (caught by
pre-existing pinyin path; treated as Chinese reading, not Japanese — a
pre-existing limitation, **not** introduced by this PR) |
| Emoji only (`😂😂😂`) | unchanged → `topic` |
| Empty / whitespace | unchanged → `topic` |

## Transliteration quality — explicit acknowledgement

`go-unidecode` is a generic Unicode → ASCII approximation. It is **not**
a per-language romanization library. Specifically:

- It will pick *one* approximation per codepoint regardless of language
context. `ใ` → `ai` (Thai romanization is `i` or `ai` depending on
standard), `한` → `han`, `語` → `Yu` (Chinese pinyin reading even when
used in Japanese), etc.
- The result is *good enough* to be a stable, URL-safe,
human-recognizable handle, but speakers of the source language will not
consider it "correct."
- It is deterministic, so the same title always produces the same slug —
important since `url_title` is recomputed on every request.

If maintainers prefer to scope this PR more narrowly (e.g. Arabic only,
and reject Thai/Hebrew/Cyrillic/etc.), the detector in
`containsNonLatin` can be tightened to specific Unicode blocks — but
that means the other scripts continue to collapse to `topic`, which is
the bug we're trying to fix. I'd argue the broader fix is preferable to
a piecemeal one, but happy to narrow if you want.

## Live deployment / real-world verification

This patch has been running in production on
**[ask.namasoft.com](https://ask.namasoft.com)** (an Apache Answer
instance we operate) since deployment, built directly from this branch
via `docker compose build`. The site hosts Arabic-language questions, so
the fix exercises the affected code path on every page load.

Sample question URL on the deployed instance:

> `https://ask.namasoft.com/questions/10010000000000115`

The slug in the URL is the transliterated Arabic title rather than
`topic`. No data migration was needed since `url_title` is computed on
every request from `Title` and never persisted (see *Why this is safe to
ship* below).

## Admin-configurable

The transliteration is gated by a package-level `atomic.Bool` (default
**on**, since the current behavior is objectively broken for affected
users):

- `htmltext.SetTransliterateNonLatin(enabled bool)`
- `htmltext.IsTransliterateNonLatinEnabled() bool`

This is deliberately the minimum surface needed to satisfy "the setting
must be readable from `UrlTitle()`". A follow-up PR can add an admin UI
section that calls `SetTransliterateNonLatin` on save and on startup,
without having to re-plumb every `htmltext.UrlTitle` call site through
`context.Context`.

**Default choice — please confirm:** I picked **default-on** because the
existing `topic` behavior is a bug for affected users. If you'd prefer
default-off for strict backward compat on existing installs, flip the
`init()` in `pkg/htmltext/htmltext.go` to `Store(false)` and surface the
toggle as opt-in.

## Why this is safe to ship

- `url_title` is **not** a persisted column. It's not on the `Question`
entity in `internal/entity/question_entity.go`, no migration has ever
added/dropped it, and every call site (`question_service.go`,
`revision_service.go`, `vote_service.go`,
search/report/review/rank/comment services, controllers, repos)
recomputes it from `Title` at response-build time via
`htmltext.UrlTitle(...)`.
- That means the fix is read-only: existing rows light up with correct
slugs on the next request, with no migration and no data rewrite.
- Rollback is just redeploying the prior image; nothing on disk changes.

## Test coverage

`pkg/htmltext/htmltext_test.go`:

- **`TestUrlTitleTable`** — table-driven, one case per affected script
(the full matrix above), plus:
  - `empty` → `topic`
  - `pure latin unchanged` → byte-identical to pre-fix
- `pure chinese unchanged` → byte-identical to pre-fix (pins existing
pinyin behavior)
- `japanese kanji goes through pinyin path unchanged` → documents the
pre-existing Han-block limitation
  - `emoji only falls back to topic` → unchanged
- `long arabic truncates at cutLongTitle boundary` → exercises the
150-byte cap and UTF-8 boundary safety
- **`TestUrlTitleTransliterationToggle`** — with the toggle off,
non-Latin titles collapse to `topic` (pre-fix behavior); with it on,
they transliterate.
- Existing `TestUrlTitle` left untouched.

Test plan for reviewers:

- [ ] `go test ./pkg/htmltext/...` — all pass
- [ ] Visit the live sample URL above and confirm slug is
transliterated, not `topic`
- [ ] Verify Chinese / Latin / emoji-only / empty behavior is
byte-identical to `main` (covered by table tests)

## Out of scope (intentionally)

- No admin UI / site setting plumbing in this PR — see
*Admin-configurable* above. Happy to do the React `Non-Latin Languages
Handling` admin page + `SiteType` + service / controller / migration in
a follow-up if maintainers want it.
- No change to the `"topic"` empty-result fallback.
- No plugin interface for slug generation — mirrored the existing
`convertChinese` pre-step pattern instead.
- No per-language romanization library — this is an explicit non-goal;
see *Transliteration quality* above.

## Issues / discussion

I didn't find an existing upstream issue covering this — happy to be
pointed at one if there is.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LinkinStars <linkinstar@foxmail.com>
2026-06-03 22:06:26 +08:00
Luffy 68085ab742 fix: update license entries 2026-06-03 22:03:58 +08:00
Luke Gao 7c210a4855 feat: add reasoning content to AI conversation records and update related components (#1530)
Fix #1524 

Root cause

DeepSeek's reasoning models stream reasoning_content alongside content.
Answer ignored it, so follow-up requests failed with 400: The
reasoning_content in the thinking mode must be passed back to the API,
and the thinking text was never shown or saved.

Fix

- Capture reasoning_content from the stream and pass it back to theAPI
on subsequent rounds.
 - Persist it with the conversation (new DB column via migrationv2.0.2).
- Render it in the chat UI as a collapsible "Thinking…/Thoughts"panel
above the answer.

Compatibility

Nullable column, omitempty field, UI hides the panel when empty — old
conversations and non-reasoning models behave exactly as before.

Demo



https://github.com/user-attachments/assets/49b1a2a1-9133-4ac2-bbeb-860215a50285
2026-05-30 15:33:54 +08:00
37 changed files with 1505 additions and 484 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ language_options:
progress: 96
- label: "Русский"
value: "ru_RU"
progress: 80
progress: 100
- label: "简体中文"
value: "zh_CN"
progress: 100
+400 -397
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -25,8 +25,12 @@ import (
"github.com/gin-gonic/gin"
)
const contentSecurityPolicy = "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http: https:; font-src 'self' data:; connect-src 'self'"
func HeadersByRequestURI() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy", contentSecurityPolicy)
c.Header("X-Content-Type-Options", "nosniff")
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
c.Header("cache-control", "public, max-age=31536000")
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestHeadersByRequestURI(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(HeadersByRequestURI())
router.GET("/", func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) })
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
if got := response.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
if got := response.Header().Get("Content-Security-Policy"); got != contentSecurityPolicy {
t.Fatalf("Content-Security-Policy = %q, want %q", got, contentSecurityPolicy)
}
}
@@ -43,6 +43,7 @@ func (am *AuthUserMiddleware) VisitAuth() gin.HandlerFunc {
siteSecurity, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
if err != nil {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
if !siteSecurity.LoginRequired {
+1 -1
View File
@@ -78,7 +78,7 @@ func NewHTTPServer(debug bool,
rootGroup := r.Group("")
swaggerRouter.Register(rootGroup)
static := r.Group(uiConf.APIBaseURL)
static.Use(avatarMiddleware.AvatarThumb(), authUserMiddleware.VisitAuth())
static.Use(authUserMiddleware.VisitAuth(), avatarMiddleware.AvatarThumb())
staticRouter.RegisterStaticRouter(static)
// The route must be available without logging in
+29 -1
View File
@@ -139,7 +139,7 @@ func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, req
}
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
if err != nil {
if err != nil || !mcpQuestionIsPublic(question) {
log.Errorf("get question failed: %v", err)
return mcp.NewToolResultText("No question found."), nil
}
@@ -161,6 +161,9 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
return nil, err
}
cond := schema.NewMCPSearchAnswerCond(request)
if len(cond.QuestionID) == 0 {
return mcp.NewToolResultText("[]"), nil
}
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
@@ -169,6 +172,10 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
}
if len(cond.QuestionID) > 0 {
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
if err != nil || !mcpQuestionIsPublic(question) {
return mcp.NewToolResultText("[]"), nil
}
answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
if err != nil {
log.Errorf("get answers failed: %v", err)
@@ -214,12 +221,33 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
}
}
func mcpQuestionIsPublic(question *schema.QuestionInfoResp) bool {
return question != nil && question.Show == entity.QuestionShow &&
(question.Status == entity.QuestionStatusAvailable || question.Status == entity.QuestionStatusClosed)
}
func (c *MCPController) mcpObjectQuestionIsPublic(ctx context.Context, objectID string) bool {
question, err := c.questioncommon.Info(ctx, objectID, "")
if err == nil {
return mcpQuestionIsPublic(question)
}
answer, exist, err := c.answerRepo.GetAnswer(ctx, objectID)
if err != nil || !exist || answer.Status != entity.AnswerStatusAvailable {
return false
}
question, err = c.questioncommon.Info(ctx, answer.QuestionID, "")
return err == nil && mcpQuestionIsPublic(question)
}
func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if err := c.ensureMCPEnabled(ctx); err != nil {
return nil, err
}
cond := schema.NewMCPSearchCommentCond(request)
if len(cond.ObjectID) == 0 || !c.mcpObjectQuestionIsPublic(ctx, cond.ObjectID) {
return mcp.NewToolResultText("No comments found."), nil
}
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
@@ -0,0 +1,50 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package controller
import (
"testing"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
)
func TestMCPQuestionIsPublic(t *testing.T) {
testCases := []struct {
name string
question *schema.QuestionInfoResp
want bool
}{
{"nil", nil, false},
{"available", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow}, true},
{"closed", &schema.QuestionInfoResp{Status: entity.QuestionStatusClosed, Show: entity.QuestionShow}, true},
{"hidden", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide}, false},
{"deleted", &schema.QuestionInfoResp{Status: entity.QuestionStatusDeleted, Show: entity.QuestionShow}, false},
{"pending", &schema.QuestionInfoResp{Status: entity.QuestionStatusPending, Show: entity.QuestionShow}, false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
if got := mcpQuestionIsPublic(testCase.question); got != testCase.want {
t.Fatalf("mcpQuestionIsPublic() = %v, want %v", got, testCase.want)
}
})
}
}
+45 -21
View File
@@ -144,13 +144,7 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanPin = canList[0]
req.CanList = canList[1]
if (req.Operation == schema.QuestionOperationPin || req.Operation == schema.QuestionOperationUnPin) && !req.CanPin {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
if (req.Operation == schema.QuestionOperationHide || req.Operation == schema.QuestionOperationShow) && !req.CanList {
if !canOperateQuestion(req.Operation, canList) {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
@@ -158,6 +152,21 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
}
func canOperateQuestion(operation string, canList []bool) bool {
switch operation {
case schema.QuestionOperationPin:
return canList[0]
case schema.QuestionOperationUnPin:
return canList[1]
case schema.QuestionOperationHide:
return canList[2]
case schema.QuestionOperationShow:
return canList[3]
default:
return true
}
}
// CloseQuestion Close question
// @Summary Close question
// @Description Close question
@@ -233,6 +242,24 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
id := ctx.Query("id")
id = uid.DeShortID(id)
userID := middleware.GetLoginUserIDFromContext(ctx)
req, err := qc.questionPermission(ctx, userID, id)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if handler.GetEnableShortID(ctx) {
info.ID = uid.EnShortID(info.ID)
}
handler.HandleResponse(ctx, nil, info)
}
func (qc *QuestionController) questionPermission(ctx *gin.Context, userID, questionID string) (schema.QuestionPermission, error) {
req := schema.QuestionPermission{}
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, userID, []string{
@@ -248,10 +275,9 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
permission.QuestionUnDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
return req, err
}
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, id)
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, questionID)
req.CanEdit = canList[0] || objectOwner
req.CanDelete = canList[1]
@@ -263,16 +289,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
req.CanShow = canList[7]
req.CanInviteOtherToAnswer = canList[8]
req.CanRecover = canList[9]
info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if handler.GetEnableShortID(ctx) {
info.ID = uid.EnShortID(info.ID)
}
handler.HandleResponse(ctx, nil, info)
return req, nil
}
// GetQuestionInviteUserInfo get question invite user info
@@ -286,7 +303,13 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
// @Router /answer/api/v1/question/invite [get]
func (qc *QuestionController) GetQuestionInviteUserInfo(ctx *gin.Context) {
questionID := uid.DeShortID(ctx.Query("id"))
resp, err := qc.questionService.InviteUserInfo(ctx, questionID)
userID := middleware.GetLoginUserIDFromContext(ctx)
per, err := qc.questionPermission(ctx, userID, questionID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
resp, err := qc.questionService.InviteUserInfo(ctx, questionID, userID, per)
handler.HandleResponse(ctx, err, resp)
}
@@ -989,6 +1012,7 @@ func (qc *QuestionController) GetQuestionLink(ctx *gin.Context) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
req.QuestionID = uid.DeShortID(req.QuestionID)
questions, total, err := qc.questionService.GetQuestionLink(ctx, req)
if err != nil {
@@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package controller
import (
"testing"
"github.com/apache/answer/internal/schema"
)
func TestCanOperateQuestionUsesMatchingPermission(t *testing.T) {
testCases := []struct {
name string
operation string
canList []bool
want bool
}{
{"pin", schema.QuestionOperationPin, []bool{true, false, false, false}, true},
{"unpin", schema.QuestionOperationUnPin, []bool{false, true, false, false}, true},
{"hide", schema.QuestionOperationHide, []bool{false, false, true, false}, true},
{"show", schema.QuestionOperationShow, []bool{false, false, false, true}, true},
{"unpin does not authorize hide", schema.QuestionOperationHide, []bool{false, true, false, false}, false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
if got := canOperateQuestion(testCase.operation, testCase.canList); got != testCase.want {
t.Fatalf("canOperateQuestion(%q, %v) = %v, want %v", testCase.operation, testCase.canList, got, testCase.want)
}
})
}
}
+1
View File
@@ -110,6 +110,7 @@ var migrations = []Migration{
NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false),
NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false),
NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true),
NewMigration("v2.0.4", "repair missing advanced site settings", repairAdvancedSiteInfo, true),
}
func GetMigrations() []Migration {
+72
View File
@@ -0,0 +1,72 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package migrations
import (
"context"
"encoding/json"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"xorm.io/builder"
"xorm.io/xorm"
)
func repairAdvancedSiteInfo(ctx context.Context, x *xorm.Engine) error {
advanced := &entity.SiteInfo{}
exists, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeAdvanced}).Get(advanced)
if err != nil {
return err
}
if exists {
return nil
}
write := &entity.SiteInfo{}
exists, err = x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeWrite}).Get(write)
if err != nil {
return err
}
if !exists {
return nil
}
siteWrite := &schema.SiteWriteResp{}
if err := json.Unmarshal([]byte(write.Content), siteWrite); err != nil {
return err
}
content, err := json.Marshal(&schema.SiteAdvancedResp{
MaxImageSize: siteWrite.MaxImageSize,
MaxAttachmentSize: siteWrite.MaxAttachmentSize,
MaxImageMegapixel: siteWrite.MaxImageMegapixel,
AuthorizedImageExtensions: siteWrite.AuthorizedImageExtensions,
AuthorizedAttachmentExtensions: siteWrite.AuthorizedAttachmentExtensions,
})
if err != nil {
return err
}
_, err = x.Context(ctx).Insert(&entity.SiteInfo{
Type: constant.SiteTypeAdvanced,
Content: string(content),
Status: 1,
})
return err
}
+101
View File
@@ -0,0 +1,101 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package migrations
import (
"context"
"testing"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
func TestRepairAdvancedSiteInfoAddsMissingSettings(t *testing.T) {
x, err := xorm.NewEngine("sqlite", ":memory:")
require.NoError(t, err)
defer func() {
_ = x.Close()
}()
require.NoError(t, x.Sync(new(entity.SiteInfo)))
_, err = x.Insert(&entity.SiteInfo{
Type: constant.SiteTypeWrite,
Content: `{"max_image_size":5}`,
Status: 1,
})
require.NoError(t, err)
var repairMigration Migration
for _, m := range GetMigrations() {
if m.Version() == "v2.0.4" {
repairMigration = m
break
}
}
require.NotNil(t, repairMigration)
require.NoError(t, repairMigration.Migrate(context.Background(), x))
advanced := &entity.SiteInfo{}
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
require.NoError(t, err)
require.True(t, exists)
assert.JSONEq(t, `{
"max_image_size": 5,
"max_attachment_size": 0,
"max_image_megapixel": 0,
"authorized_image_extensions": null,
"authorized_attachment_extensions": null
}`, advanced.Content)
}
func TestRepairAdvancedSiteInfoPreservesExistingSettings(t *testing.T) {
x, err := xorm.NewEngine("sqlite", ":memory:")
require.NoError(t, err)
defer func() {
_ = x.Close()
}()
require.NoError(t, x.Sync(new(entity.SiteInfo)))
const existingContent = `{"max_image_size":99}`
_, err = x.Insert(
&entity.SiteInfo{
Type: constant.SiteTypeWrite,
Content: `{invalid`,
Status: 1,
},
&entity.SiteInfo{
Type: constant.SiteTypeAdvanced,
Content: existingContent,
Status: 1,
},
)
require.NoError(t, err)
require.NoError(t, repairAdvancedSiteInfo(context.Background(), x))
advanced := &entity.SiteInfo{}
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
require.NoError(t, err)
require.True(t, exists)
assert.JSONEq(t, existingContent, advanced.Content)
}
+10 -4
View File
@@ -817,10 +817,9 @@ func (qr *questionRepo) UpdateQuestionLinkStatus(ctx context.Context, status int
}
// GetQuestionLink get linked question to questionID
func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
questionList = make([]*entity.Question, 0)
questionID = uid.DeShortID(questionID)
questionStatus := []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending}
if questionID == "0" {
return nil, 0, errors.InternalServer(reason.DatabaseError).WithError(
fmt.Errorf("questionID is empty"),
@@ -833,8 +832,15 @@ func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int,
Where("question_link.to_question_id = ? AND question.show = ?", questionID, entity.QuestionShow).
Distinct("question.id").
Where("question_link.status = ?", entity.QuestionLinkStatusAvailable).
Select("question.*").
In("question.status", questionStatus)
Select("question.*")
switch {
case isAdminModerator:
session.Where("question.status IN (?, ?, ?)", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending)
case loginUserID != "":
session.Where("(question.status IN (?, ?) OR (question.status = ? AND question.user_id = ?))", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending, loginUserID)
default:
session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
}
switch orderCond {
case "newest":
@@ -0,0 +1,85 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package repo_test
import (
"context"
"testing"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/repo/question"
"github.com/apache/answer/internal/repo/unique"
"github.com/stretchr/testify/require"
)
func TestQuestionRepoGetQuestionLinkRespectsPendingVisibility(t *testing.T) {
ctx := context.Background()
questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
newQuestion := func(userID, title string, status, show int) *entity.Question {
q := &entity.Question{
UserID: userID,
Title: title,
OriginalText: title,
ParsedText: title,
Status: status,
Show: show,
}
require.NoError(t, questionRepo.AddQuestion(ctx, q))
return q
}
target := newQuestion("link-target-owner", "link target", entity.QuestionStatusAvailable, entity.QuestionShow)
available := newQuestion("link-author", "available", entity.QuestionStatusAvailable, entity.QuestionShow)
pendingOwner := newQuestion("link-author", "pending owner", entity.QuestionStatusPending, entity.QuestionShow)
pendingOther := newQuestion("link-other", "pending other", entity.QuestionStatusPending, entity.QuestionShow)
hidden := newQuestion("link-author", "hidden", entity.QuestionStatusAvailable, entity.QuestionHide)
questions := []*entity.Question{target, available, pendingOwner, pendingOther, hidden}
for _, from := range questions[1:] {
_, err := testDataSource.DB.Context(ctx).Insert(&entity.QuestionLink{
FromQuestionID: from.ID,
ToQuestionID: target.ID,
Status: entity.QuestionLinkStatusAvailable,
})
require.NoError(t, err)
}
t.Cleanup(func() {
_, _ = testDataSource.DB.Context(ctx).Where("to_question_id = ?", target.ID).Delete(&entity.QuestionLink{})
for _, q := range questions {
_, _ = testDataSource.DB.Context(ctx).ID(q.ID).Delete(&entity.Question{})
}
})
assertLinkedIDs := func(loginUserID string, isAdminModerator bool, want ...string) {
got, _, err := questionRepo.GetQuestionLink(ctx, 1, 20, target.ID, loginUserID, isAdminModerator, "newest", 0)
require.NoError(t, err)
gotIDs := make([]string, 0, len(got))
for _, q := range got {
gotIDs = append(gotIDs, q.ID)
}
require.ElementsMatch(t, want, gotIDs)
}
assertLinkedIDs("", false, available.ID)
assertLinkedIDs("link-author", false, available.ID, pendingOwner.ID)
assertLinkedIDs("link-other", false, available.ID, pendingOther.ID)
assertLinkedIDs("link-moderator", true, available.ID, pendingOwner.ID, pendingOther.ID)
}
+19 -3
View File
@@ -21,7 +21,6 @@ package tag_common
import (
"context"
"fmt"
"strconv"
"strings"
@@ -171,10 +170,20 @@ func (tr *tagCommonRepo) GetTagPage(ctx context.Context, page, pageSize int, tag
session := tr.data.DB.Context(ctx)
if len(tag.SlugName) > 0 {
// Both sides lowered, so the search is case-insensitive.
//
// This previously read LOWER(%s) formatted against the *search term*,
// which put the function name into the value: the query became
// slug_name LIKE '%LOWER(coco)%' and could never match. Only the
// display_name clause did anything, and that is case-sensitive on
// Postgres, so typing a tag in lower case -- which is how tags are
// written and therefore how anyone types them -- returned nothing at all
// and read as "no such tag".
search := searchTermForTag(tag.SlugName)
mainTagCond := builder.And(
builder.Or(
builder.Like{"slug_name", fmt.Sprintf("LOWER(%s)", tag.SlugName)},
builder.Like{"display_name", tag.SlugName},
builder.Like{"LOWER(slug_name)", search},
builder.Like{"LOWER(display_name)", search},
),
builder.Eq{"main_tag_id": 0},
)
@@ -293,3 +302,10 @@ func (tr *tagCommonRepo) UpdateTagsAttribute(ctx context.Context, tags []string,
}
return
}
// searchTermForTag normalises a tag search term. Lowering it here, and lowering
// the columns in the query, is what makes the search case-insensitive: tags are
// written in lower case, so that is how people type them.
func searchTermForTag(term string) string {
return strings.ToLower(strings.TrimSpace(term))
}
@@ -0,0 +1,36 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package tag_common
import "testing"
// The bug: the search term was formatted into LOWER(%s), which put the function
// name into the value rather than applying it to the column, so the query became
// slug_name LIKE '%LOWER(coco)%' and matched nothing. Only display_name did any
// work, and that is case-sensitive on Postgres -- so typing a tag the way tags
// are actually written returned "no such tag".
func TestSearchTermIsLoweredNotWrapped(t *testing.T) {
for _, in := range []string{"Coco", "COCO", "coco"} {
got := searchTermForTag(in)
if got != "coco" {
t.Errorf("searchTermForTag(%q) = %q, want %q", in, got, "coco")
}
}
}
+16 -4
View File
@@ -53,10 +53,11 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
filePath := c.Param("filepath")
// The original filename is 123.pdf
originalFilename := filepath.Base(filePath)
// The real filename is hash.pdf
realFilename := strings.TrimSuffix(filePath, "/"+originalFilename) + filepath.Ext(originalFilename)
// The file local path is /uploads/files/post/hash.pdf
fileLocalPath := filepath.Join(a.serviceConfig.UploadPath, constant.FilesPostSubPath, realFilename)
fileLocalPath, ok := attachmentFileLocalPath(a.serviceConfig.UploadPath, filePath, originalFilename)
if !ok {
c.Redirect(http.StatusFound, "/404")
return
}
// If the file is not exist, return 404
if !dir.CheckFileExist(fileLocalPath) {
c.Redirect(http.StatusFound, "/404")
@@ -65,3 +66,14 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
c.FileAttachment(fileLocalPath, originalFilename)
})
}
func attachmentFileLocalPath(uploadPath, requestPath, originalFilename string) (string, bool) {
realFilename := strings.TrimSuffix(requestPath, "/"+originalFilename) + filepath.Ext(originalFilename)
attachmentRoot := filepath.Join(uploadPath, constant.FilesPostSubPath)
fileLocalPath := filepath.Join(attachmentRoot, realFilename)
relPath, err := filepath.Rel(attachmentRoot, fileLocalPath)
if err != nil || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
return "", false
}
return fileLocalPath, true
}
+46
View File
@@ -0,0 +1,46 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package router
import (
"path/filepath"
"testing"
"github.com/apache/answer/internal/base/constant"
)
func TestAttachmentFileLocalPathRejectsTraversal(t *testing.T) {
uploadPath := t.TempDir()
filePath, ok := attachmentFileLocalPath(uploadPath, "/hash/report.pdf", "report.pdf")
if !ok {
t.Fatal("valid attachment path was rejected")
}
want := filepath.Join(uploadPath, constant.FilesPostSubPath, "hash.pdf")
if filePath != want {
t.Fatalf("attachment path = %q, want %q", filePath, want)
}
for _, requestPath := range []string{"/../../outside/secret.txt", "/hash/../../../secret.txt"} {
if _, ok := attachmentFileLocalPath(uploadPath, requestPath, filepath.Base(requestPath)); ok {
t.Fatalf("traversal path %q was accepted", requestPath)
}
}
}
+6 -1
View File
@@ -21,6 +21,7 @@ package schema
import (
"encoding/json"
"slices"
"github.com/apache/answer/internal/base/constant"
)
@@ -28,7 +29,7 @@ import (
const (
AccountActivationSourceType EmailSourceType = "account-activation"
PasswordResetSourceType EmailSourceType = "password-reset"
ConfirmNewEmailSourceType EmailSourceType = "password-reset"
ConfirmNewEmailSourceType EmailSourceType = "confirm-new-email"
UnsubscribeSourceType EmailSourceType = "unsubscribe"
BindingSourceType EmailSourceType = "binding"
)
@@ -56,6 +57,10 @@ func (r *EmailCodeContent) FromJSONString(data string) error {
return json.Unmarshal([]byte(data), &r)
}
func (r *EmailCodeContent) IsSourceType(sourceTypes ...EmailSourceType) bool {
return slices.Contains(sourceTypes, r.SourceType)
}
type RegisterTemplateData struct {
SiteName string
RegisterUrl string
+41
View File
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package schema
import "testing"
func TestEmailCodeContentIsSourceType(t *testing.T) {
if PasswordResetSourceType == ConfirmNewEmailSourceType {
t.Fatal("password reset and confirm new email source types must be distinct")
}
passwordResetCode := &EmailCodeContent{SourceType: PasswordResetSourceType}
if !passwordResetCode.IsSourceType(PasswordResetSourceType) {
t.Fatal("password reset code should match the password reset source type")
}
if passwordResetCode.IsSourceType(UnsubscribeSourceType, ConfirmNewEmailSourceType) {
t.Fatal("password reset code must not match another source type")
}
unsubscribeCode := &EmailCodeContent{SourceType: UnsubscribeSourceType}
if unsubscribeCode.IsSourceType(PasswordResetSourceType, AccountActivationSourceType) {
t.Fatal("unsubscribe code must not match an account credential source type")
}
}
+7 -7
View File
@@ -514,13 +514,13 @@ type PersonalCollectionPageReq struct {
}
type GetQuestionLinkReq struct {
Page int `validate:"omitempty,min=1" form:"page"`
PageSize int `validate:"omitempty,min=1,max=100" form:"page_size"`
QuestionID string `validate:"required" form:"question_id"`
OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
InDays int `validate:"omitempty,min=1" form:"in_days"`
LoginUserID string `json:"-"`
Page int `validate:"omitempty,min=1" form:"page"`
PageSize int `validate:"omitempty,min=1,max=100" form:"page_size"`
QuestionID string `validate:"required" form:"question_id"`
OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
InDays int `validate:"omitempty,min=1" form:"in_days"`
LoginUserID string `json:"-"`
IsAdminModerator bool `json:"-"`
}
type GetQuestionLinkResp struct {
-3
View File
@@ -26,7 +26,6 @@ import (
"github.com/apache/answer/internal/service/apikey"
"github.com/apache/answer/pkg/token"
"github.com/apache/answer/plugin"
"github.com/segmentfault/pacman/log"
)
// AuthRepo auth repository
@@ -198,9 +197,7 @@ func (as *AuthService) AuthAPIKey(ctx context.Context, read bool, apiKey string)
}
// If the request is not read-only, check if the API key has write permissions
if !read && apiKeyInfo.Scope == "read-only" {
log.Warnf("API key %s does not have write permissions", apiKeyInfo.AccessKey)
return false, nil
}
log.Infof("API key %s is valid, scope: %s", apiKeyInfo.AccessKey, apiKeyInfo.Scope)
return true, nil
}
+99
View File
@@ -0,0 +1,99 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package auth
import (
"context"
"fmt"
"strings"
"testing"
"github.com/apache/answer/internal/entity"
"github.com/segmentfault/pacman/log"
)
func TestAuthAPIKeyDoesNotLogAccessKey(t *testing.T) {
const accessKey = "sk_sensitive-api-key-must-not-be-logged"
logger := &authTestLogger{}
previousLogger := log.GetLogger()
log.SetLogger(logger)
t.Cleanup(func() { log.SetLogger(previousLogger) })
service := NewAuthService(nil, &authTestAPIKeyRepo{
key: &entity.APIKey{AccessKey: accessKey, Scope: "read-only"},
})
pass, err := service.AuthAPIKey(context.Background(), true, accessKey)
if err != nil || !pass {
t.Fatalf("read-only API key should authenticate read request: pass=%v err=%v", pass, err)
}
pass, err = service.AuthAPIKey(context.Background(), false, accessKey)
if err != nil || pass {
t.Fatalf("read-only API key should not authenticate write request: pass=%v err=%v", pass, err)
}
if logs := logger.String(); strings.Contains(logs, accessKey) {
t.Fatalf("authentication logs contain API key: %s", logs)
}
}
type authTestAPIKeyRepo struct {
key *entity.APIKey
}
func (r *authTestAPIKeyRepo) GetAPIKeyList(context.Context) ([]*entity.APIKey, error) {
return nil, nil
}
func (r *authTestAPIKeyRepo) GetAPIKey(context.Context, string) (*entity.APIKey, bool, error) {
return r.key, true, nil
}
func (r *authTestAPIKeyRepo) UpdateAPIKey(context.Context, entity.APIKey) error { return nil }
func (r *authTestAPIKeyRepo) AddAPIKey(context.Context, entity.APIKey) error { return nil }
func (r *authTestAPIKeyRepo) DeleteAPIKey(context.Context, int) error { return nil }
func (r *authTestAPIKeyRepo) DeleteAPIKeysByUserID(context.Context, string) error { return nil }
type authTestLogger struct {
entries []string
}
func (l *authTestLogger) Debug(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
func (l *authTestLogger) Debugf(format string, v ...any) {
l.entries = append(l.entries, fmt.Sprintf(format, v...))
}
func (l *authTestLogger) Info(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
func (l *authTestLogger) Infof(format string, v ...any) {
l.entries = append(l.entries, fmt.Sprintf(format, v...))
}
func (l *authTestLogger) Warn(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
func (l *authTestLogger) Warnf(format string, v ...any) {
l.entries = append(l.entries, fmt.Sprintf(format, v...))
}
func (l *authTestLogger) Error(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
func (l *authTestLogger) Errorf(format string, v ...any) {
l.entries = append(l.entries, fmt.Sprintf(format, v...))
}
func (l *authTestLogger) String() string { return strings.Join(l.entries, "\n") }
+21 -9
View File
@@ -1091,13 +1091,8 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
if err != nil {
return
}
// If the question is deleted or pending, only the administrator and the author can view it
if (question.Status == entity.QuestionStatusDeleted ||
question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
return nil, errors.NotFound(reason.QuestionNotFound)
}
if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
return nil, errors.NotFound(reason.QuestionNotFound)
if err = checkQuestionVisibility(question, userID, per); err != nil {
return nil, err
}
if question.Status != entity.QuestionStatusClosed {
per.CanReopen = false
@@ -1142,6 +1137,19 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
return question, nil
}
func checkQuestionVisibility(question *schema.QuestionInfoResp, userID string, per schema.QuestionPermission) error {
// Deleted and pending questions are visible only to their author or users who can reopen them.
if (question.Status == entity.QuestionStatusDeleted ||
question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
return errors.NotFound(reason.QuestionNotFound)
}
// Hidden questions are visible only to their author or an administrator/moderator.
if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
return errors.NotFound(reason.QuestionNotFound)
}
return nil
}
// GetQuestionAndAddPV get question one
func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID, loginUserID string,
per schema.QuestionPermission) (
@@ -1153,7 +1161,11 @@ func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID,
return qs.GetQuestion(ctx, questionID, loginUserID, per)
}
func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID string) (inviteList []*schema.UserBasicInfo, err error) {
func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID, userID string,
per schema.QuestionPermission) (inviteList []*schema.UserBasicInfo, err error) {
if _, err = qs.GetQuestion(ctx, questionID, userID, per); err != nil {
return nil, err
}
return qs.questioncommon.InviteUserInfo(ctx, questionID)
}
@@ -1748,7 +1760,7 @@ func (qs *QuestionService) GetQuestionLink(ctx context.Context, req *schema.GetQ
req.InDays = schema.HotInDays
}
questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.OrderCond, req.InDays)
questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.LoginUserID, req.IsAdminModerator, req.OrderCond, req.InDays)
if err != nil {
return nil, 0, err
}
@@ -0,0 +1,65 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package content
import (
"testing"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
)
func TestCheckQuestionVisibility(t *testing.T) {
testCases := []struct {
name string
status int
show int
userID string
viewer string
per schema.QuestionPermission
allow bool
}{
{"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "", schema.QuestionPermission{}, true},
{"pending question anonymous", entity.QuestionStatusPending, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
{"pending question author", entity.QuestionStatusPending, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, true},
{"pending question reviewer", entity.QuestionStatusPending, entity.QuestionShow, "author", "reviewer", schema.QuestionPermission{CanReopen: true}, true},
{"deleted question anonymous", entity.QuestionStatusDeleted, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
{"hidden question anonymous", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "", schema.QuestionPermission{}, false},
{"hidden question author", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", schema.QuestionPermission{}, true},
{"hidden question moderator", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "moderator", schema.QuestionPermission{IsAdminModerator: true}, true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
question := &schema.QuestionInfoResp{
Status: testCase.status,
Show: testCase.show,
UserID: testCase.userID,
}
err := checkQuestionVisibility(question, testCase.viewer, testCase.per)
if testCase.allow && err != nil {
t.Fatalf("visibility unexpectedly denied: %v", err)
}
if !testCase.allow && err == nil {
t.Fatal("visibility unexpectedly allowed")
}
})
}
}
+22 -9
View File
@@ -227,8 +227,9 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
// send email
data := &schema.EmailCodeContent{
Email: req.Email,
UserID: userInfo.ID,
SourceType: schema.PasswordResetSourceType,
Email: req.Email,
UserID: userInfo.ID,
}
code := token.GenerateToken()
verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code)
@@ -247,6 +248,9 @@ func (us *UserService) UpdatePasswordWhenForgot(ctx context.Context, req *schema
if err != nil {
return errors.BadRequest(reason.EmailVerifyURLExpired)
}
if !data.IsSourceType(schema.PasswordResetSourceType) {
return errors.BadRequest(reason.EmailVerifyURLExpired)
}
userInfo, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
if err != nil {
@@ -598,8 +602,9 @@ func applyRegistrationVerification(
func (us *UserService) sendRegistrationActivationEmail(ctx context.Context, userInfo *entity.User) error {
data := &schema.EmailCodeContent{
Email: userInfo.EMail,
UserID: userInfo.ID,
SourceType: schema.AccountActivationSourceType,
Email: userInfo.EMail,
UserID: userInfo.ID,
}
code := token.GenerateToken()
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
@@ -621,8 +626,9 @@ func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) e
}
data := &schema.EmailCodeContent{
Email: userInfo.EMail,
UserID: userInfo.ID,
SourceType: schema.AccountActivationSourceType,
Email: userInfo.EMail,
UserID: userInfo.ID,
}
code := token.GenerateToken()
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
@@ -640,6 +646,9 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri
if err != nil {
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
}
if !data.IsSourceType(schema.AccountActivationSourceType, schema.BindingSourceType) {
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
}
userInfo, has, err := us.userRepo.GetByEmail(ctx, data.Email)
if err != nil {
@@ -736,8 +745,9 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
}
data := &schema.EmailCodeContent{
Email: req.Email,
UserID: req.UserID,
SourceType: schema.ConfirmNewEmailSourceType,
Email: req.Email,
UserID: req.UserID,
}
code := token.GenerateToken()
var title, body string
@@ -763,6 +773,9 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string
if err != nil {
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
}
if !data.IsSourceType(schema.ConfirmNewEmailSourceType) {
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
}
_, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
if err != nil {
@@ -896,7 +909,7 @@ func (us *UserService) UserUnsubscribeNotification(
ctx context.Context, req *schema.UserUnsubscribeNotificationReq) (err error) {
data := &schema.EmailCodeContent{}
err = data.FromJSONString(req.Content)
if err != nil || len(data.UserID) == 0 {
if err != nil || len(data.UserID) == 0 || !data.IsSourceType(schema.UnsubscribeSourceType) {
return errors.BadRequest(reason.EmailVerifyURLExpired)
}
@@ -20,14 +20,57 @@
package content
import (
"context"
"errors"
"testing"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEmailCodePurposeIsEnforcedBeforeUserMutation(t *testing.T) {
service := &UserService{}
ctx := context.Background()
t.Run("password reset rejects a code issued for another purpose", func(t *testing.T) {
content := (&schema.EmailCodeContent{
SourceType: schema.UnsubscribeSourceType,
Email: "user@example.test",
}).ToJSONString()
err := service.UpdatePasswordWhenForgot(ctx, &schema.UserRePassWordRequest{Content: content})
if err == nil {
t.Fatal("password reset accepted an unsubscribe code")
}
})
t.Run("email activation rejects a password reset code", func(t *testing.T) {
content := (&schema.EmailCodeContent{
SourceType: schema.PasswordResetSourceType,
Email: "user@example.test",
}).ToJSONString()
_, err := service.UserVerifyEmail(ctx, &schema.UserVerifyEmailReq{Content: content})
if err == nil {
t.Fatal("email activation accepted a password reset code")
}
})
t.Run("change email rejects a password reset code", func(t *testing.T) {
content := (&schema.EmailCodeContent{
SourceType: schema.PasswordResetSourceType,
Email: "user@example.test",
}).ToJSONString()
_, err := service.UserChangeEmailVerify(ctx, content)
if err == nil {
t.Fatal("change email accepted a password reset code")
}
})
}
func TestApplyRegistrationVerification(t *testing.T) {
t.Run("required sends activation email and leaves email pending", func(t *testing.T) {
userInfo := &entity.User{}
+18 -11
View File
@@ -32,6 +32,7 @@ import (
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
usercommon "github.com/apache/answer/internal/service/user_common"
"github.com/apache/answer/pkg/converter"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
@@ -70,7 +71,7 @@ func (ip *ImporterService) NewImporterFunc() plugin.ImporterFunc {
}
func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plugin.QuestionImporterInfo) (err error) {
req := &schema.QuestionAdd{}
req := newImportedQuestionRequest(questionInfo)
errFields := make([]*validator.FormErrorField, 0)
// To limit rate, remove the following code from comment: Part 1/2
// reject, rejectKey := ipc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req)
@@ -94,16 +95,6 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
// }
// }()
req.UserID = userInfo.ID
req.Title = questionInfo.Title
req.Content = questionInfo.Content
req.HTML = "<p>" + questionInfo.Content + "</p>"
req.Tags = make([]*schema.TagItem, len(questionInfo.Tags))
for i, tag := range questionInfo.Tags {
req.Tags[i] = &schema.TagItem{
SlugName: tag,
DisplayName: tag,
}
}
canList, requireRanks, err := ip.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{
permission.QuestionAdd,
permission.QuestionEdit,
@@ -169,3 +160,19 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
log.Info("Add Question Successfully")
return nil
}
func newImportedQuestionRequest(questionInfo plugin.QuestionImporterInfo) *schema.QuestionAdd {
req := &schema.QuestionAdd{
Title: questionInfo.Title,
Content: questionInfo.Content,
HTML: converter.Markdown2HTML(questionInfo.Content),
Tags: make([]*schema.TagItem, len(questionInfo.Tags)),
}
for i, tag := range questionInfo.Tags {
req.Tags[i] = &schema.TagItem{
SlugName: tag,
DisplayName: tag,
}
}
return req
}
@@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package importer
import (
"strings"
"testing"
"github.com/apache/answer/plugin"
)
func TestNewImportedQuestionRequestSanitizesContent(t *testing.T) {
request := newImportedQuestionRequest(plugin.QuestionImporterInfo{
Title: "Imported question",
Content: `<img src=x onerror=alert(1)><script>alert(1)</script>`,
Tags: []string{"security"},
})
for _, unsafeContent := range []string{"onerror", "<script"} {
if strings.Contains(strings.ToLower(request.HTML), unsafeContent) {
t.Fatalf("imported question HTML contains unsafe content %q: %s", unsafeContent, request.HTML)
}
}
}
@@ -58,7 +58,7 @@ func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canMe
})
}
if canRecover && status == entity.QuestionStatusDeleted {
if canRecover && status == entity.TagStatusDeleted {
actions = append(actions, &schema.PermissionMemberAction{
Action: "undelete",
Name: translator.Tr(lang, undeleteActionName),
+1 -1
View File
@@ -88,7 +88,7 @@ type QuestionRepo interface {
RemoveQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
RecoverQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
UpdateQuestionLinkStatus(ctx context.Context, status int, links ...*entity.QuestionLink) (err error)
GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
}
// QuestionCommon user service
+7 -6
View File
@@ -319,13 +319,13 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
if err := ctx.SaveUploadedFile(file, filePath); err != nil {
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
src, err := file.Open()
if err != nil {
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
saved := false
defer func() {
_ = src.Close()
if !saved {
if removeErr := os.Remove(filePath); removeErr != nil && !os.IsNotExist(removeErr) {
log.Errorf("remove failed uploaded file failed: %v", removeErr)
}
}
}()
if !checker.DecodeAndCheckImageFile(filePath, siteAdvanced.GetMaxImageMegapixel()) {
@@ -337,6 +337,7 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
}
url = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, fileSubPath)
saved = true
return url, nil
}
+6 -4
View File
@@ -597,8 +597,9 @@ func (us *UserAdminService) GetUserActivation(ctx context.Context, req *schema.G
}
data := &schema.EmailCodeContent{
Email: userInfo.EMail,
UserID: userInfo.ID,
SourceType: schema.AccountActivationSourceType,
Email: userInfo.EMail,
UserID: userInfo.ID,
}
code := token.GenerateToken()
us.emailService.SaveCode(ctx, userInfo.ID, code, data.ToJSONString())
@@ -624,8 +625,9 @@ func (us *UserAdminService) SendUserActivation(ctx context.Context, req *schema.
}
data := &schema.EmailCodeContent{
Email: userInfo.EMail,
UserID: userInfo.ID,
SourceType: schema.AccountActivationSourceType,
Email: userInfo.EMail,
UserID: userInfo.ID,
}
code := token.GenerateToken()
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", general.SiteUrl, code)
+2
View File
@@ -61,6 +61,8 @@ func DecodeAndCheckImageFile(localFilePath string, maxImageMegapixel int) bool {
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, webpImageCheck) {
return false
}
default:
return false
}
return true
}
+36
View File
@@ -0,0 +1,36 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package checker
import (
"os"
"path/filepath"
"testing"
)
func TestDecodeAndCheckImageFileRejectsUnsupportedExtension(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "not-an-image.svg")
if err := os.WriteFile(filePath, []byte("<svg></svg>"), 0o600); err != nil {
t.Fatal(err)
}
if DecodeAndCheckImageFile(filePath, 1_000_000) {
t.Fatal("unsupported image extensions must be rejected")
}
}
+78
View File
@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package converter
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRenderLinkIsUrl(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
{"absolute http URL", "http://example.com/path?q=1#f", true},
{"absolute https URL", "https://example.com", true},
{"ftp URL", "ftp://example.com/file", true},
{"uppercase scheme and host", "HTTP://EXAMPLE.COM", false},
{"bare domain", "example.com", true},
{"bare domain with path", "example.com/questions/123", true},
{"www subdomain", "www.example.com", true},
{"bare IP", "10.0.0.1", true},
{"IP with port and path", "10.0.0.1:8080/a", true},
{"host with port", "localhost:8080", true},
{"domain with port and path", "example.com:8080/x", true},
{"IPv6 with port", "[::1]:8080", true},
{"userinfo", "user:pass@example.com", true},
{"mailto", "mailto:a@b.com", true},
{"email-like destination", "a@b.co", true},
{"userinfo without scheme", "user@h.co", true},
{"trailing dot FQDN", "example.com.", true},
{"empty", "", false},
{"single word", "foo", false},
{"path segment no dot", "questions/123", false},
{"absolute path", "/questions/123", true},
{"scheme-less authority path", "//cdn.example.com/x", true},
{"anchor", "#section", false},
{"leading dot", ".hidden", false},
{"javascript scheme", "javascript:alert(1)", false},
{"tel scheme", "tel:+1234", false},
{"host with leading dot", "http://.example.com", false},
{"trailing colon", "example.com:", false},
{"single label with scheme", "http://localhost", true},
{"single label no scheme no port", "localhost", false},
{"not a url", "not a url", false},
{"whitespace in path", "h.co/p q", false},
{"label with leading hyphen", "-ex.com", false},
{"label with trailing hyphen", "ex-.com", false},
{"invalid IPv4 quad", "999.1.1.1", false},
{"IPv4 with leading zeros", "01.2.3.4", false},
{"three letter domain", "a.b", false},
}
r := &DangerousHTMLRenderer{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, r.renderLinkIsUrl(tc.in))
})
}
}