diff --git a/.github/workflows/build-binary-for-release.yml b/.github/workflows/build-binary-for-release.yml index 2bce67142..76f1c9530 100644 --- a/.github/workflows/build-binary-for-release.yml +++ b/.github/workflows/build-binary-for-release.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.18.1 + node-version: 20.19.0 - name: Node Build run: make install-ui-packages ui diff --git a/.gitignore b/.gitignore index ba66f51a0..d5aed0da5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,10 +17,8 @@ /go.work* /logs /ui/node_modules -/ui/build/*/*/* -/ui/build/*.json -/ui/build/*.html -/ui/build/*.txt +/ui/build/* +!/ui/build/favicon.ico /vendor Thumbs*.db tmp diff --git a/Makefile b/Makefile index 0623e1efd..74e51886b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: build clean ui -VERSION=2.0.2 +VERSION=2.0.3 BIN=answer DIR_SRC=./cmd/answer DOCKER_CMD=docker diff --git a/README.md b/README.md index cf257aba2..0d958e007 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache ### Running with docker ```bash -docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.2 +docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.3 ``` For more information, see [Installation](https://answer.apache.org/docs/installation). @@ -40,7 +40,7 @@ You can also check out the [plugins here](https://answer.apache.org/plugins). ### Prerequisites -- Golang >= 1.23 +- Golang >= 1.25 - Node.js >= 20 - pnpm >= 9 - [mockgen](https://github.com/uber-go/mock?tab=readme-ov-file#installation) >= 0.6.0 diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index f1b9d1862..57d57ac29 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -101,7 +101,7 @@ spec: - name: data {{- if .Values.persistence.enabled }} persistentVolumeClaim: - claimName: {{ include "answer.fullname" . }}-claim + claimName: {{ .Values.persistence.existingClaim | default (printf "%s-claim" (include "answer.fullname" .)) }} {{- else }} emptyDir: {} {{- end -}} diff --git a/charts/templates/pvc.yaml b/charts/templates/pvc.yaml index 640fb9fe0..e01ff820f 100644 --- a/charts/templates/pvc.yaml +++ b/charts/templates/pvc.yaml @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -{{ if .Values.persistence.enabled -}} +{{ if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}} kind: PersistentVolumeClaim apiVersion: v1 metadata: diff --git a/charts/values.yaml b/charts/values.yaml index d932db848..7a5c16f3b 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -91,6 +91,8 @@ persistence: accessMode: ReadWriteOnce size: 5Gi annotations: {} + # Use an existing PVC instead of creating one; when set, no PVC is created by this chart + existingClaim: "" # To restore a PVC from a VolumeSnapshot, set the dataSource; # the kind and apiGroup are optional and default to the shown values dataSource: {} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 446f6cc0b..66e162784 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -274,7 +274,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, permissionController := controller.NewPermissionController(rankService) userPluginController := controller.NewUserPluginController(pluginCommonService) reviewController := controller.NewReviewController(reviewService, rankService, captchaService) - metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, eventqueueService) + metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, objService, eventqueueService) metaController := controller.NewMetaController(metaService) badgeGroupRepo := badge_group.NewBadgeGroupRepo(dataData, uniqueIDRepo) eventRuleRepo := badge.NewEventRuleRepo(dataData) diff --git a/go.mod b/go.mod index 5787c8b18..7b68c180c 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( go.uber.org/mock v0.6.0 golang.org/x/crypto v0.53.0 golang.org/x/image v0.20.0 + golang.org/x/net v0.56.0 golang.org/x/term v0.44.0 golang.org/x/text v0.39.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df @@ -170,7 +171,6 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.10.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect - golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/i18n/de_DE.yaml b/i18n/de_DE.yaml index 151341c60..90a88ff0f 100644 --- a/i18n/de_DE.yaml +++ b/i18n/de_DE.yaml @@ -71,7 +71,7 @@ backend: user: other: Standard ohne speziellen Zugriff. admin: - other: Habe die volle Berechtigung, auf die Seite zuzugreifen. + other: Hat die volle Berechtigung, auf die Seite zuzugreifen. moderator: other: Hat Zugriff auf alle Beiträge außer Admin-Einstellungen. privilege: @@ -266,7 +266,7 @@ backend: cannot_set_synonym_as_itself: other: Du kannst das Synonym des aktuellen Tags nicht als sich selbst festlegen. minimum_count: - other: Not enough tags were entered. + other: Nicht genügend Tags angegeben. smtp: config_from_name_cannot_be_email: other: Der Absendername kann keine E-Mail-Adresse sein. @@ -311,13 +311,13 @@ backend: add_bulk_users_amount_error: other: "Die Anzahl der Benutzer, die du auf einmal hinzufügst, sollte im Bereich von 1-{{.MaxAmount}} liegen." status_suspended_forever: - other: "This user was suspended forever. This user doesn't meet a community guideline." + other: "Dieser Benutzer wurde dauerhaft gesperrt. Dieser Benutzer verstößt gegen eine Community-Richtlinie." status_suspended_until: - other: "This user was suspended until {{.SuspendedUntil}}. This user doesn't meet a community guideline." + other: "Dieser Benutzer wird bis {{.SuspendedUntil}} gesperrt. Dieser Benutzer verstößt gegen eine Community-Richtlinie." status_deleted: - other: "This user was deleted." + other: "Dieser Benutzer wurde gelösch." status_inactive: - other: "This user is inactive." + other: "Dieser Benutzer ist inaktiv." config: read_config_failed: other: Lesekonfiguration fehlgeschlagen @@ -456,7 +456,7 @@ backend: comment_answer: other: kommentierte Antwort reply_to_you: - other: hat Ihnen geantwortet + other: hat dir geantwortet mention_you: other: hat dich erwähnt your_question_is_closed: @@ -598,7 +598,7 @@ backend: name: other: Kommentator desc: - other: Hinterlassen Sie 5 Kommentare. + other: Hinterlasse 5 Kommentare. new_user_of_the_month: name: other: Neuer Benutzer des Monats @@ -606,14 +606,14 @@ backend: other: Ausstehende Beiträge in ihrem ersten Monat. read_guidelines: name: - other: Lesen Sie die Richtlinien + other: Lese die Richtlinien desc: - other: Lesen Sie die [Community-Richtlinien]. + other: Lese die [Community-Richtlinien]. reader: name: other: Leser desc: - other: Lesen Sie alle Antworten in einem Thema mit mehr als 10 Antworten. + other: Lese alle Antworten in einem Thema mit mehr als 10 Antworten. welcome: name: other: Willkommen @@ -761,7 +761,7 @@ backend: other: Fragen mit 25 oder mehr Punkten. great_question: name: - other: Große Frage + other: Großartige Frage desc: other: Frage mit 50 oder mehr Punkten. popular_question: @@ -809,7 +809,16 @@ ui: how_to_format: title: Wie man formatiert desc: >- - + pagination: prev: Zurück next: Weiter @@ -821,7 +830,7 @@ ui: tag_wiki: tag Wiki create_tag: Tag erstellen edit_tag: Tag bearbeiten - ask_a_question: Create Question + ask_a_question: Frage erstellen edit_question: Frage bearbeiten edit_answer: Antwort bearbeiten search: Suchen @@ -846,16 +855,16 @@ ui: http_403: HTTP Fehler 403 logout: Ausloggen posts: Posts - ai_assistant: AI Assistant + ai_assistant: KI-Assistent ai_assistant: - description: Got a question? Ask it and get answers, perspectives, and recommendations. - recent_conversations: Recent Conversations - show_more: Show more - new: New chat - ai_generate: AI-generated from posts and may not be accurate. - copy: Copy - ask_a_follow_up: Ask a follow-up - ask_placeholder: Ask a question + description: Was möchtest du wissen? Stell eine Frage und erhalte Antworten, Sichtweisen und Empfehlungen. + recent_conversations: Letzte Unterhaltungen + show_more: Mehr anzeigen + new: Neuer Chat + ai_generate: KI-generierte Inhalte aus Beiträgen sind möglicherweise nicht korrekt + copy: Kopieren + ask_a_follow_up: Eine Folgefrage stellen + ask_placeholder: Stell eine Frage notifications: title: Benachrichtigungen inbox: Posteingang @@ -882,7 +891,7 @@ ui: blockquote: text: Blockzitat bold: - text: Stark + text: Fett chart: text: Bestenliste flow_chart: Flussdiagramm @@ -924,7 +933,7 @@ ui: help: text: Hilfe hr: - text: Horizontale Richtlinie + text: Horizontale Linie image: text: Bild add_image: Bild hinzufügen @@ -957,7 +966,7 @@ ui: outdent: text: Ausrücken italic: - text: Hervorhebung + text: Kursiv link: text: Hyperlink add_link: Hyperlink hinzufügen @@ -981,7 +990,7 @@ ui: cell: Zelle file: text: Datei anhängen - not_supported: "Diesen Dateityp nicht unterstützen. Versuchen Sie es erneut mit {{file_type}}." + not_supported: "Diesen Dateityp nicht unterstützen. Versuche es erneut mit {{file_type}}." max_size: "Dateigröße anhängen darf {{size}} MB nicht überschreiten." close_modal: title: Ich schließe diesen Beitrag als... @@ -1047,9 +1056,9 @@ ui: delete: title: Diesen Tag löschen tip_with_posts: >- -

Wir erlauben es nicht, Tags mit Beiträgenzu löschen.

Bitte entfernen Sie dieses Tag zuerst aus den Beiträgen.

+

Wir erlauben es nicht, Tags mit Beiträgenzu löschen.

Bitte entferne dieses Tag zuerst aus den Beiträgen.

tip_with_synonyms: >- -

Wir erlauben nicht Tags mit Synonymenzu löschen.

Bitte entfernen Sie zuerst die Synonyme von diesem Schlagwort.

+

Wir erlauben nicht Tags mit Synonymenzu löschen.

Bitte entferne zuerst die Synonyme von diesem Schlagwort.

tip: Bist du sicher, dass du löschen möchtest? close: Schließen merge: @@ -1076,12 +1085,12 @@ ui: x_minutes_ago: "Vor {{count}}m" x_hours_ago: "Vor {{count}}h" hour: Stunde - day: tag + day: Tag hours: Stunden days: Tage - month: month - months: months - year: year + month: Monat + months: Monate + year: Jahr reaction: heart: Herz smile: Lächeln @@ -1137,10 +1146,10 @@ ui: more: Mehr wiki: Wiki ask: - title: Create Question + title: Frage erstellen edit_title: Frage bearbeiten default_reason: Frage bearbeiten - default_first_reason: Create question + default_first_reason: Frage erstellen similar_questions: Ähnliche Fragen form: fields: @@ -1148,17 +1157,17 @@ ui: label: Version title: label: Titel - placeholder: What's your topic? Be specific. + placeholder: Was ist das Thema? msg: empty: Der Titel darf nicht leer sein. range: Titel bis zu 150 Zeichen body: - label: Körper + label: Inhalt msg: - empty: Körper darf nicht leer sein. + empty: Inhalt darf nicht leer sein. hint: - optional_body: Describe what the question is about. - minimum_characters: "Describe what the question is about, at least {{min_content_length}} characters are required." + optional_body: Beschreibe worum es in der Frage geht. + minimum_characters: "Erläutere mit wenigstens {{min_content_length}} Zeichen, worum es in der Frage geht." tags: label: Stichworte msg: @@ -1179,9 +1188,9 @@ ui: add_btn: Schlagwort hinzufügen create_btn: Neuen Tag erstellen search_tag: Tag suchen - hint: Describe what your content is about, at least one tag is required. - hint_zero_tags: Describe what your content is about. - hint_more_than_one_tag: "Describe what your content is about, at least {{min_tags_number}} tags are required." + hint: Beschreibe den Inhalt. Mindestens ein Tag ist erforderlich. + hint_zero_tags: Beschreibe den Inhalt. + hint_more_than_one_tag: "Beschreibe mit mindestens {{min_tags_number}} Tags, worum es bei diesem Thema geht." no_result: Keine Tags gefunden tag_required_text: Benötigter Tag (mindestens eins) header: @@ -1233,7 +1242,7 @@ ui: msg: empty: Der Name darf nicht leer sein. range: Der Name muss zwischen 2 und 30 Zeichen lang sein. - character: 'Must use the character set "a-z", "0-9", " - . _"' + character: 'Erlaubte Zeichen sind "a-z", "0-9", " - . _"' email: label: E-Mail msg: @@ -1253,7 +1262,7 @@ ui: msg: empty: E-Mail darf nicht leer sein. change_email: - btn_cancel: Stornieren + btn_cancel: Abbrechen btn_update: E-Mail Adresse aktualisieren send_success: >- Wenn ein Konto mit {{mail}} übereinstimmt, solltest du in Kürze eine E-Mail mit Anweisungen erhalten, wie du dein Passwort zurücksetzen kannst. @@ -1311,7 +1320,7 @@ ui: caption: Leute können dich als "@Benutzername" erwähnen. msg: Benutzername darf nicht leer sein. msg_range: Der Benutzername muss zwischen 2 und 30 Zeichen lang sein. - character: 'Must use the character set "a-z", "0-9", "- . _"' + character: 'Erlaubte Zeichen sind "a-z", "0-9", "- . _"' avatar: label: Profilbild gravatar: Gravatar @@ -1389,9 +1398,9 @@ ui: title: Related answers: antworten linked_question: - title: Linked - description: Posts linked to - no_linked_question: No contents linked from this content. + title: Verlinkt + description: Beitrag verlinkt nach + no_linked_question: Zu diesem Inhalt sind keine Links vorhanden. invite_to_answer: title: Frage jemanden desc: Lade Leute ein, von denen du glaubst, dass sie die Antwort wissen könnten. @@ -1400,7 +1409,7 @@ ui: search: Personen suchen question_detail: action: Aktion - created: Created + created: Erstellt Asked: Gefragt asked: gefragt update: Geändert @@ -1451,11 +1460,11 @@ ui: list: confirm_btn: Liste title: Diesen Beitrag auflisten - content: Möchten Sie diesen Beitrag wirklich in der Liste anzeigen? + content: Möchtest du diesen Beitrag wirklich in der Liste anzeigen? unlist: confirm_btn: Von Liste nehmen title: Diesen Beitrag von der Liste nehmen - content: Möchten Sie diesen Beitrag wirklich aus der Liste ausblenden? + content: Möchtest du diesen Beitrag wirklich aus der Liste ausblenden? pin: title: Diesen Beitrag anpinnen content: Bist du sicher, dass du den Beitrag global anheften möchtest? Dieser Beitrag wird in allen Beitragslisten ganz oben erscheinen. @@ -1533,7 +1542,7 @@ ui: follow: Folgen following: Folgend counts: "{{count}} Ergebnisse" - counts_loading: "... Results" + counts_loading: "... Ergebnisse" more: Mehr sort_btns: relevance: Relevanz @@ -1562,12 +1571,12 @@ ui: title: Fehler... delete_permanently: title: Endgültig löschen - content: Sind Sie sicher, dass Sie den Inhalt endgültig löschen möchten? + content: Bist du sicher, dass du den Inhalt endgültig löschen möchtest? account_result: success: Dein neues Konto ist bestätigt; du wirst zur Startseite weitergeleitet. link: Weiter zur Startseite oops: Hoppla! - invalid: Der Link, den Sie verwendet haben, funktioniert nicht mehr. + invalid: Der verwendete Link funktioniert nicht mehr. confirm_new_email: Deine E-Mail wurde aktualisiert. confirm_new_email_invalid: >- Dieser Bestätigungslink ist leider nicht mehr gültig. Vielleicht wurde deine E-Mail-Adresse bereits geändert? @@ -1710,13 +1719,13 @@ ui: empty: Kontakt-E-Mail kann nicht leer sein. incorrect: Falsches Format der Kontakt-E-Mail. login_required: - label: Privat + label: Privater Bereich switch: Anmeldung erforderlich text: Nur eingeloggte Benutzer können auf diese Community zugreifen. admin_name: label: Name msg: Der Name darf nicht leer sein. - character: 'Must use the character set "a-z", "0-9", " - . _"' + character: 'Erlaubte Zeichen sind "a-z", "0-9", " - . _"' msg_max_length: Der Name muss zwischen 2 und 30 Zeichen lang sein. admin_password: label: Passwort @@ -1727,7 +1736,7 @@ ui: msg_max_length: Das Passwort darf maximal 32 Zeichen lang sein. admin_confirm_password: label: "Passwort bestätigen" - text: "Bitte geben Sie Ihr Passwort erneut ein, um es zu bestätigen." + text: "Bitte gib dein Passwort erneut ein, um es zu bestätigen." msg: "Passwortbestätigung stimmt nicht überein!" admin_email: label: E-Mail @@ -1777,7 +1786,7 @@ ui: branding: Branding legal: Rechtliches write: Schreiben - terms: Terms + terms: Nutzungsbedingungen tos: Nutzungsbedingungen privacy: Privatsphäre seo: SEO @@ -1789,16 +1798,16 @@ ui: installed_plugins: Installierte Plugins apperance: Erscheinungsbild community: Community - advanced: Advanced + advanced: Erweitert tags: Tags - rules: Rules - policies: Policies - security: Security - files: Files - apikeys: API Keys - intelligence: Intelligence - ai_assistant: AI Assistant - ai_settings: AI Settings + rules: Regeln + policies: Richtlinien + security: Sicherheit + files: Dateien + apikeys: API-Schlüssel + intelligence: Intelligenz + ai_assistant: KI-Assistent + ai_settings: KI-Einstellungen mcp: MCP website_welcome: Willkommen auf {{site_name}} user_center: @@ -1808,7 +1817,7 @@ ui: badges: modal: title: Glückwunsch - content: Sie haben sich ein neues Abzeichen verdient. + content: Du hast dir ein neues Abzeichen verdient. close: Schließen confirm: Abzeichen ansehen title: Abzeichen @@ -1833,7 +1842,7 @@ ui: users: "Nutzer:" flags: "Meldungen:" reviews: "Rezension:" - site_health: Gesundheit der Website + site_health: Status der Website version: "Version:" https: "HTTPS:" upload_folder: "Hochladeverzeichnis:" @@ -1914,7 +1923,7 @@ ui: form: fields: users: - label: Masse Benutzer hinzufügen + label: Mehrere Benutzer hinzufügen placeholder: "John Smith, john@example.com, BUSYopr2\nAlice, alice@example.com, fpDntV8q" text: Trenne "Name, E-Mail, Passwort" mit Kommas. Ein Benutzer pro Zeile. msg: "Bitte gib die E-Mail des Nutzers ein, eine pro Zeile." @@ -1937,7 +1946,7 @@ ui: created_at: Angelegt am delete_at: Löschzeit suspend_at: Sperrzeit - suspend_until: Suspend until + suspend_until: Gesperrt bis status: Status role: Rolle action: Aktion @@ -1972,8 +1981,8 @@ ui: suspend_user: title: Diesen Benutzer sperren content: Ein gesperrter Benutzer kann sich nicht einloggen. - label: How long will the user be suspended for? - forever: Forever + label: Wie lang soll dieser Benutzer gesperrt sein? + forever: Für immer questions: page_title: Fragen unlisted: Nicht gelistet @@ -2035,11 +2044,11 @@ ui: msg: Die Zeitzone darf nicht leer sein. text: Wähle eine Stadt in der gleichen Zeitzone wie du. avatar: - label: Default avatar - text: For users without a custom avatar of their own. + label: Standard-Avatar + text: Für Nutzer, die keinen eigenen Avatar haben. gravatar_base_url: - label: Gravatar base URL - text: URL of the Gravatar provider's API base. Ignored when empty. + label: Gravatar Basis-URL + text: URL zur API des Gravatar-Anbieters. Wird ignoriert, wenn leer. smtp: page_title: SMTP from_email: @@ -2111,17 +2120,17 @@ ui: always_display: Externen Inhalt immer anzeigen ask_before_display: Vor der Anzeige externer Inhalte fragen write: - page_title: Files + page_title: Dateien min_content: - label: Minimum question body length - text: Minimum allowed question body length in characters. + label: Minimale Länge für den Inhalt einer Frage + text: Minimale Anzahl an Zeichen für den Inhalt einer Frage. restrict_answer: title: Antwort bearbeiten label: Jeder Benutzer kann für jede Frage nur eine Antwort schreiben - text: "Schalten Sie aus, um es Benutzern zu ermöglichen, mehrere Antworten auf dieselbe Frage zu schreiben, was dazu führen kann, dass Antworten nicht im Fokus stehen." + text: "Schalte es aus, um es Benutzern zu ermöglichen, mehrere Antworten auf dieselbe Frage zu schreiben, was dazu führen kann, dass Antworten nicht im Fokus stehen." min_tags: - label: "Minimum tags per question" - text: "Minimum number of tags required in a question." + label: "Minimum an Tags pro Frage" + text: "Benötigte minimale Anzahl an Tags pro Frage." recommend_tags: label: Empfohlene Tags text: "Empfohlene Tags werden standardmäßig in der Dropdown-Liste angezeigt." @@ -2141,14 +2150,14 @@ ui: label: Maximale Anhanggröße (MB) text: "Die maximale Dateigröße für Dateianhänge." image_megapixels: - label: Max. BildmePixel - text: "Maximale Anzahl an Megapixeln für ein Bild." + label: Max. Megapixel pro Bild + text: "Maximale Anzahl an Megapixel für ein Bild." image_extensions: - label: Autorisierte Bilderweiterungen - text: "Eine Liste von Dateierweiterungen, die für die Anzeige von Bildern erlaubt sind, getrennt durch Kommata." + label: Erlaubte Bilderweiterungen + text: "Eine Liste von Dateierweiterungen, die für die Anzeige von Bildern erlaubt sind, getrennt durch Kommas." attachment_extensions: - label: Autorisierte Anhänge Erweiterungen - text: "Eine Liste von Dateierweiterungen, die für das Hochladen erlaubt sind, getrennt mit Kommas. WARNUNG: Erlaubt Uploads kann Sicherheitsprobleme verursachen." + label: Erlaubte Anhänge Erweiterungen + text: "Eine Liste von Dateierweiterungen, die für das Hochladen erlaubt sind, getrennt mit Kommas. WARNUNG: Der Erlaubten von Uploads kann Sicherheitsprobleme verursachen." seo: page_title: SEO permalink: @@ -2168,28 +2177,25 @@ ui: label: Hintergrundstil der Navigationsleiste primary_color: label: Primäre Farbe - text: Ändere die Farben, die von deinen Themes verwendet werden + text: Ändere die Farben, die von deinen Themen verwendet werden layout: label: Layout - full_width: Full-width - fixed_width: Fixed-width + full_width: Volle Breite + fixed_width: Feste Breite css_and_html: page_title: CSS und HTML custom_css: - label: Benutzerdefinierte CSS - text: > - + label: Benutzerdefiniertes CSS + text: Dies wird als <link> eingefügt. head: - label: Kopf - text: > - + label: Head + text: Dies wird vor </head> eingefügt. header: label: Header - text: > - + text: Dies wird nach <body> eingefügt. footer: label: Fusszeile - text: Dies wird vor eingefügt. + text: Dies wird vor </body> eingefügt. sidebar: label: Seitenleiste text: Dies wird in die Seitenleiste eingefügt. @@ -2203,16 +2209,20 @@ ui: title: E-Mail Registrierung label: E-Mail-Registrierung zulassen text: Abschalten, um zu verhindern, dass jemand ein neues Konto per E-Mail erstellt. + email_verification: + title: E-Mail Überprüfung + label: E-Mail-Adresse überprüfen + text: Wenn aktiv müssen Anwender ihre E-Mail-Adresse verifizieren, bevor sie sie hier verwenden. allowed_email_domains: title: Zugelassene E-Mail-Domänen text: E-Mail-Domänen, bei denen die Nutzer Konten registrieren müssen. Eine Domäne pro Zeile. Wird ignoriert, wenn leer. private: - title: Privatgelände + title: Privater Bereich label: Anmeldung erforderlich text: Nur angemeldete Benutzer können auf diese Community zugreifen. password_login: title: Passwort-Login - label: E-Mail-und Passwort-Login erlauben + label: E-Mail- und Passwort-Login erlauben text: "WARNUNG: Wenn du diese Option abschaltest, kannst du dich möglicherweise nicht mehr anmelden, wenn du zuvor keine andere Anmeldemethode konfiguriert hast." installed_plugins: title: Installierte Plugins @@ -2278,69 +2288,69 @@ ui: status: Status title: Abzeichen apikeys: - title: API Keys - add_api_key: Add API Key - desc: Description + title: API-Schlüssel + add_api_key: API-Schlüssel hinzufügen + desc: Beschreibung scope: Scope - key: Key - created: Created - last_used: Last used + key: Schlüssel + created: Erstellt + last_used: Zuletzt verwendet add_or_edit_modal: - add_title: Add API Key - edit_title: Edit API Key - description: Description - description_required: Description is required. + add_title: API-Schlüssel hinzufügen + edit_title: API-Schüssel ändern + description: Beschreibung + description_required: Beschreibung benötigt. scope: Scope global: Global - read-only: Read-only + read-only: Nur lesen created_modal: - title: API key created - api_key: API key - description: This key will not be displayed again. Make sure you take a copy before continuing. + title: API-Schlüssel erstellt + api_key: API-Schlüssel + description: Dieser Schlüssel wird nicht erneut angezeigt. Erstellt dir jetzt eine Kopie. delete_modal: - title: Delete API Key - content: Any applications or scripts using this key will no longer be able to access the API. This is permanent! + title: API-Schlüssel löschen + content: Alle Anwendungen und Skripte, die diesen Schlüssel verwenden, können zukünftig nicht mehr auf die API zugreifen. ai_settings: enabled: - label: AI enabled - check: Enable AI features - text: The AI model must be configured correctly before it can be used. + label: KI benutzen + check: KI-Funktionen aktivieren + text: Das KI-Modell muss korrekt eingerichtet sein, bevor es verwendet werden kann. provider: - label: Provider + label: Anbieter api_host: - label: API host - msg: API host is required + label: API-Server + msg: API-Server wird benötigt api_key: - label: API key - check: Check - check_success: "Connection successful." - msg: API key is required + label: API-Schlüssel + check: Prüfen + check_success: "Verbindung erfolgreich." + msg: API-Schlüssel wird benötigt model: - label: Model - msg: Model is required + label: Modell + msg: Modell wird benötigt add_success: AI settings updated successfully. conversations: - topic: Topic - helpful: Helpful - unhelpful: Unhelpful - created: Created - action: Action - empty: No conversations found. + topic: Thema + helpful: Hilfreich + unhelpful: Nicht hilfreich + created: Erstellt + action: Aktion + empty: Keine Unterhaltungen vorhanden. delete_modal: - title: Delete conversation - content: Are you sure you want to delete this conversation? This is permanent! - delete_success: Conversation deleted successfully. + title: Unterhaltung löschen + content: Diese Unterhaltung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden! + delete_success: Unterhaltung gelöscht. mcp: mcp_server: - label: MCP server - switch: Enabled + label: MCP-Server + switch: Aktiv type: - label: Type + label: Typ url: label: URL http_header: - label: HTTP header - text: Please replace {key} with the API Key. + label: HTTP-Kopfzeilen + text: Ersetze {key} durch den API-Schlüssel. form: optional: (optional) empty: kann nicht leer sein @@ -2359,10 +2369,10 @@ ui: edit_answer: Antwort bearbeiten edit_tag: Tag bearbeiten empty: Keine Überprüfungsaufgaben mehr übrig. - approve_revision_tip: Akzeptieren Sie diese Revision? - approve_flag_tip: Sind Sie mit diesem Bericht einverstanden? - approve_post_tip: Bestätigen Sie diesen Beitrag? - approve_user_tip: Bestätigen Sie diesen Benutzer? + approve_revision_tip: Akzeptierst du diese Revision? + approve_flag_tip: Bist du mit diesem Bericht einverstanden? + approve_post_tip: Diesen Beitrag bestätigen? + approve_user_tip: Diesen Benutzer bestätigen? suggest_edits: Änderungsvorschläge flag_post: Beitrag melden flag_user: Nutzer melden @@ -2401,7 +2411,7 @@ ui: title_for_question: "Zeitleiste für" title_for_answer: "Zeitachse für die Antwort auf {{ title }} von {{ author }}" title_for_tag: "Zeitachse für Tag" - datetime: Terminzeit + datetime: Zeitangabe type: Typ by: Von comment: Kommentar @@ -2423,7 +2433,7 @@ ui: post_cancel_deleted: Dieser Beitrag wurde wiederhergestellt. post_pin: Dieser Beitrag wurde angepinnt. post_unpin: Dieser Beitrag wurde losgelöst. - post_hide_list: Dieser Beitrag wurde aus der Liste verborgen. + post_hide_list: Dieser Beitrag wurde von der Liste verborgen. post_show_list: Dieser Beitrag wird in der Liste angezeigt. post_reopen: Dieser Beitrag wurde wieder geöffnet. post_list: Dieser Beitrag wurde angezeigt. @@ -2446,5 +2456,3 @@ ui: copy: In die Zwischenablage kopieren copied: Kopiert external_content_warning: Externe Bilder/Medien werden nicht angezeigt. - - diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..ac4191be0 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -2247,7 +2247,7 @@ ui: text: When enabled, users must verify their email address before using the site. allowed_email_domains: title: Allowed email domains - text: Email domains that users must register accounts with. One domain per line. Ignored when empty. + text: Email domains users must use to register accounts. Enter one domain per line, including the @ symbol (for example, @example.com). Ignored when empty. private: title: Private label: Login required @@ -2488,4 +2488,3 @@ ui: copy: Copy to clipboard copied: Copied external_content_warning: External images/media are not displayed. - diff --git a/i18n/i18n.yaml b/i18n/i18n.yaml index 7abb748db..e2c89c69b 100644 --- a/i18n/i18n.yaml +++ b/i18n/i18n.yaml @@ -43,7 +43,7 @@ language_options: progress: 96 - label: "Русский" value: "ru_RU" - progress: 80 + progress: 100 - label: "简体中文" value: "zh_CN" progress: 100 diff --git a/i18n/ru_RU.yaml b/i18n/ru_RU.yaml index 525083210..e716faa95 100644 --- a/i18n/ru_RU.yaml +++ b/i18n/ru_RU.yaml @@ -140,7 +140,7 @@ backend: pass: other: Пароль old_pass: - other: Current password + other: Текущий пароль original_text: other: Это сообщение email_or_password_wrong_error: @@ -182,7 +182,7 @@ backend: cannot_edit_after_deadline: other: Невозможно редактировать комментарий из-за того, что он был создан слишком давно. content_cannot_empty: - other: Comment content cannot be empty. + other: Содержимое комментария не может быть пустым. email: duplicate: other: Адрес электронной почты уже существует. @@ -233,9 +233,9 @@ backend: cannot_update: other: Нет разрешения на обновление. content_cannot_empty: - other: . + other: Содержимое не может быть пустым content_less_than_minimum: - other: Not enough content entered. + other: Введено недостаточно содержимого. rank: fail_to_meet_the_condition: other: Ранг репутации не соответствует условию. @@ -266,7 +266,7 @@ backend: cannot_set_synonym_as_itself: other: Вы не можете установить синоним текущего тега. minimum_count: - other: Not enough tags were entered. + other: Введено недостаточно тегов. smtp: config_from_name_cannot_be_email: other: Поле отправителя не может содержать email адрес. @@ -311,13 +311,13 @@ backend: add_bulk_users_amount_error: other: "Количество пользователей, которое Вы добавляете, должно быть в промежутке от 1 до {{.MaxAmount}}." status_suspended_forever: - other: "This user was suspended forever. This user doesn't meet a community guideline." + other: "Этот пользователь заблокирован навсегда. Этот пользователь не соответствует правилам сообщества." status_suspended_until: - other: "This user was suspended until {{.SuspendedUntil}}. This user doesn't meet a community guideline." + other: "Этот пользователь заблокирован до {{.SuspendedUntil}}. Этот пользователь не соответствует правилам сообщества." status_deleted: - other: "This user was deleted." + other: "Этот пользователь удален." status_inactive: - other: "This user is inactive." + other: "Этот пользователь неактивен." config: read_config_failed: other: Не удалось прочитать конфигурацию @@ -506,7 +506,7 @@ backend: title: other: "[{{.SiteName}}] Новый вопрос: {{.QuestionTitle}}" body: - other: "{{.QuestionTitle}}
\n{{.Tags}}

\n\n--
\nNote: This is an automatic system email, please do not reply to this message as your response will not be seen.

\n\nUnsubscribe" + other: "{{.QuestionTitle}}
\n{{.Tags}}

\n\n--
\nПримечание: Данное сообщение является автоматическим, отвечать на него не нужно.

\n\nОтписаться" pass_reset: title: other: "[{{.SiteName }}] Пароль сброшен" @@ -576,37 +576,37 @@ backend: other: Впервые добавить голос в сообщении. first_link: name: - other: First Link + other: Первая ссылка desc: - other: First added a link to another post. + other: Впервые добавил ссылку на другой пост. first_reaction: name: - other: First Reaction + other: Первая реакция desc: - other: First reacted to the post. + other: Впервые отреагировал на пост. first_share: name: - other: First Share + other: Первый репост desc: - other: First shared a post. + other: Впервые поделился постом. scholar: name: - other: Scholar + other: Ученик desc: - other: Asked a question and accepted an answer. + other: Задал вопрос и принял ответ. commentator: name: - other: Commentator + other: Комментатор desc: other: Оставить 5 комментариев. new_user_of_the_month: name: - other: New User of the Month + other: Новичок месяца desc: - other: Outstanding contributions in their first month. + other: Выдающийся вклад в первый месяц. read_guidelines: name: - other: Read Guidelines + other: Прочитал правила desc: other: Прочтите [правила сообщества]. reader: @@ -623,193 +623,193 @@ backend: name: other: Неплохо поделился desc: - other: Shared a post with 25 unique visitors. + other: Поделился постом с 25 уникальными посетителями. good_share: name: - other: Good Share + other: Хороший репост desc: - other: Shared a post with 300 unique visitors. + other: Поделился постом с 300 уникальными посетителями. great_share: name: - other: Great Share + other: Отличный репост desc: - other: Shared a post with 1000 unique visitors. + other: Поделился постом с 1000 уникальными посетителями. out_of_love: name: - other: Out of Love + other: От любви desc: - other: Used 50 up votes in a day. + other: Поставил 50 голосов «за» за день. higher_love: name: - other: Higher Love + other: Большая любовь desc: - other: Used 50 up votes in a day 5 times. + other: Поставил 50 голосов «за» за день 5 раз. crazy_in_love: name: - other: Crazy in Love + other: Без ума от любви desc: - other: Used 50 up votes in a day 20 times. + other: Поставил 50 голосов «за» за день 20 раз. promoter: name: - other: Promoter + other: Промоутер desc: - other: Invited a user. + other: Пригласил пользователя. campaigner: name: - other: Campaigner + other: Агитатор desc: - other: Invited 3 basic users. + other: Пригласил 3 базовых пользователей. champion: name: - other: Champion + other: Чемпион desc: - other: Invited 5 members. + other: Пригласил 5 участников. thank_you: name: other: Спасибо desc: - other: Has 20 up voted posts and gave 10 up votes. + other: Имеет 20 постов с голосами «за» и отдал 10 голосов «за». gives_back: name: - other: Gives Back + other: Отдает взамен desc: - other: Has 100 up voted posts and gave 100 up votes. + other: Имеет 100 постов с голосами «за» и отдал 100 голосов «за». empathetic: name: - other: Empathetic + other: Чуткий desc: - other: Has 500 up voted posts and gave 1000 up votes. + other: Имеет 500 постов с голосами «за» и отдал 1000 голосов «за». enthusiast: name: - other: Enthusiast + other: Энтузиаст desc: - other: Visited 10 consecutive days. + other: Заходил 10 дней подряд. aficionado: name: - other: Aficionado + other: Поклонник desc: - other: Visited 100 consecutive days. + other: Заходил 100 дней подряд. devotee: name: - other: Devotee + other: Преданный desc: - other: Visited 365 consecutive days. + other: Заходил 365 дней подряд. anniversary: name: - other: Anniversary + other: Годовщина desc: - other: Активный участник на год, опубликовал по крайней мере один раз. + other: Активный участник в течение года с минимум одной публикацией. appreciated: name: - other: Appreciated + other: Оцененный desc: - other: Received 1 up vote on 20 posts. + other: Получил 1 голос «за» на 20 постах. respected: name: - other: Respected + other: Уважаемый desc: - other: Received 2 up votes on 100 posts. + other: Получил 2 голоса «за» на 100 постах. admired: name: - other: Admired + other: Признанный desc: - other: Received 5 up votes on 300 posts. + other: Получил 5 голосов «за» на 300 постах. solved: name: - other: Solved + other: Решено desc: - other: Have an answer be accepted. + other: Один из ваших ответов был принят. guidance_counsellor: name: - other: Guidance Counsellor + other: Наставник desc: - other: Have 10 answers be accepted. + other: Принято 10 ваших ответов. know_it_all: name: - other: Know-it-All + other: Всезнайка desc: - other: Have 50 answers be accepted. + other: Принято 50 ваших ответов. solution_institution: name: - other: Solution Institution + other: Кладезь решений desc: - other: Have 150 answers be accepted. + other: Принято 150 ваших ответов. nice_answer: name: - other: Nice Answer + other: Хороший ответ desc: - other: Answer score of 10 or more. + other: Рейтинг ответа 10 или более. good_answer: name: - other: Good Answer + other: Отличный ответ desc: - other: Answer score of 25 or more. + other: Рейтинг ответа 25 или более. great_answer: name: - other: Great Answer + other: Превосходный ответ desc: - other: Answer score of 50 or more. + other: Рейтинг ответа 50 или более. nice_question: name: - other: Nice Question + other: Хороший вопрос desc: - other: Question score of 10 or more. + other: Рейтинг вопроса 10 или более. good_question: name: - other: Good Question + other: Отличный вопрос desc: - other: Question score of 25 or more. + other: Рейтинг вопроса 25 или более. great_question: name: - other: Great Question + other: Превосходный вопрос desc: - other: Question score of 50 or more. + other: Рейтинг вопроса 50 или более. popular_question: name: - other: Popular Question + other: Популярный вопрос desc: - other: Question with 500 views. + other: Вопрос с 500 просмотрами. notable_question: name: - other: Notable Question + other: Заметный вопрос desc: - other: Question with 1,000 views. + other: Вопрос с 1000 просмотрами. famous_question: name: - other: Famous Question + other: Знаменитый вопрос desc: - other: Question with 5,000 views. + other: Вопрос с 5000 просмотрами. popular_link: name: - other: Popular Link + other: Популярная ссылка desc: - other: Posted an external link with 50 clicks. + other: Опубликовал внешнюю ссылку с 50 переходами. hot_link: name: - other: Hot Link + other: Горячая ссылка desc: - other: Posted an external link with 300 clicks. + other: Опубликовал внешнюю ссылку с 300 переходами. famous_link: name: - other: Famous Link + other: Знаменитая ссылка desc: - other: Posted an external link with 100 clicks. + other: Опубликовал внешнюю ссылку с 100 переходами. default_badge_groups: getting_started: name: - other: Getting Started + other: Начало работы community: name: - other: Community + other: Сообщество posting: name: - other: Posting + other: Публикации # The following fields are used for interface presentation(Front-end) ui: how_to_format: title: 'Форматирование:' desc: >- - + pagination: prev: Назад next: Следующий @@ -821,7 +821,7 @@ ui: tag_wiki: wiki тэг create_tag: Создать тег edit_tag: Изменить тег - ask_a_question: Create Question + ask_a_question: Создать вопрос edit_question: Редактировать вопрос edit_answer: Редактировать ответ search: Поиск @@ -845,17 +845,17 @@ ui: http_50X: Ошибка HTTP 500 http_403: Ошибка HTTP 403 logout: Выйти - posts: Posts - ai_assistant: AI Assistant + posts: Посты + ai_assistant: AI-ассистент ai_assistant: - description: Got a question? Ask it and get answers, perspectives, and recommendations. - recent_conversations: Recent Conversations - show_more: Show more - new: New chat - ai_generate: AI-generated from posts and may not be accurate. - copy: Copy - ask_a_follow_up: Ask a follow-up - ask_placeholder: Ask a question + description: Есть вопрос? Задайте его и получите ответы, мнения и рекомендации. + recent_conversations: Недавние обсуждения + show_more: Показать еще + new: Новый чат + ai_generate: Сгенерировано ИИ на основе постов и может быть неточным. + copy: Копировать + ask_a_follow_up: Задать уточняющий вопрос + ask_placeholder: Задайте вопрос notifications: title: Уведомления inbox: Входящие @@ -981,8 +981,8 @@ ui: cell: Ячейка file: text: Прикрепить файлы - not_supported: "Don’t support that file type. Try again with {{file_type}}." - max_size: "Attach files size cannot exceed {{size}} MB." + not_supported: "Этот тип файла не поддерживается. Попробуйте снова с {{file_type}}." + max_size: "Размер прикрепляемых файлов не может превышать {{size}} МБ." close_modal: title: Я закрываю этот пост как... btn_cancel: Отменить @@ -1047,20 +1047,20 @@ ui: delete: title: Удалить этот тег tip_with_posts: >- -

We do not allow deleting tag with posts.

Please remove this tag from the posts first.

+

Мы не разрешаем удалять тег с постами.

Сначала удалите этот тег из постов.

tip_with_synonyms: >- -

We do not allow deleting tag with synonyms.

Please remove the synonyms from this tag first.

+

Мы не разрешаем удалять тег с синонимами.

Сначала удалите синонимы у этого тега.

tip: Вы уверены, что хотите удалить? close: Закрыть merge: - title: Merge tag - source_tag_title: Source tag - source_tag_description: The source tag and its associated data will be remapped to the target tag. - target_tag_title: Target tag - target_tag_description: A synonym between these two tags will be created after merging. - no_results: No tags matched - btn_submit: Submit - btn_close: Close + title: Объединить тег + source_tag_title: Исходный тег + source_tag_description: Исходный тег и связанные с ним данные будут переназначены на целевой тег. + target_tag_title: Целевой тег + target_tag_description: После объединения между этими двумя тегами будет создан синоним. + no_results: Нет подходящих тегов + btn_submit: Отправить + btn_close: Закрыть edit_tag: title: Изменить тег default_reason: Правка тега @@ -1079,17 +1079,17 @@ ui: day: дней hours: часов days: дней - month: month - months: months - year: year + month: месяц + months: месяцев + year: год reaction: heart: сердечко - smile: smile - frown: frown + smile: улыбка + frown: недовольство btn_label: добавить или удалить реакции undo_emoji: отменить реакцию {{ emoji }} - react_emoji: react with {{ emoji }} - unreact_emoji: unreact with {{ emoji }} + react_emoji: поставить реакцию {{ emoji }} + unreact_emoji: убрать реакцию {{ emoji }} comment: btn_add_comment: Добавить комментарий reply_to: Ответить на @@ -1135,12 +1135,12 @@ ui: search_placeholder: Фильтр по названию тега no_desc: Тег не имеет описания. more: Подробнее - wiki: Wiki + wiki: Вики ask: - title: Create Question + title: Создать вопрос edit_title: Редактировать вопрос default_reason: Редактировать вопрос - default_first_reason: Create question + default_first_reason: Создать вопрос similar_questions: Похожие вопросы form: fields: @@ -1148,7 +1148,7 @@ ui: label: Версия title: label: Заголовок - placeholder: What's your topic? Be specific. + placeholder: О чем ваш вопрос? Сформулируйте конкретно. msg: empty: Заголовок не может быть пустым. range: Заголовок должен быть меньше 150 символов @@ -1157,8 +1157,8 @@ ui: msg: empty: Вопрос не может быть пустым. hint: - optional_body: Describe what the question is about. - minimum_characters: "Describe what the question is about, at least {{min_content_length}} characters are required." + optional_body: Опишите, о чем ваш вопрос. + minimum_characters: "Опишите, о чем ваш вопрос, требуется минимум {{min_content_length}} символов." tags: label: Теги msg: @@ -1179,9 +1179,9 @@ ui: add_btn: Тег create_btn: новый тег search_tag: Поиск тега - hint: Describe what your content is about, at least one tag is required. - hint_zero_tags: Describe what your content is about. - hint_more_than_one_tag: "Describe what your content is about, at least {{min_tags_number}} tags are required." + hint: Опишите, о чем ваш контент, требуется минимум один тег. + hint_zero_tags: Опишите, о чем ваш контент. + hint_more_than_one_tag: "Опишите, о чем ваш контент, требуется минимум {{min_tags_number}} тегов." no_result: Нет соответствующих тэгов tag_required_text: Обязательный тег (хотя бы один) header: @@ -1200,7 +1200,7 @@ ui: search: placeholder: Поиск footer: - build_on: Powered by <1> Apache Answer + build_on: Сделано с помощью <1> Apache Answer upload_img: name: Изменить loading: загрузка... @@ -1232,8 +1232,8 @@ ui: label: Имя пользователя msg: empty: Имя пользователя не должно быть пустым. - range: Name must be between 2 to 30 characters in length. - character: 'Must use the character set "a-z", "0-9", " - . _"' + range: Имя должно содержать от 2 до 30 символов. + character: 'Используйте набор символов "a-z", "0-9", " - . _"' email: label: Email адрес msg: @@ -1305,13 +1305,13 @@ ui: display_name: label: Отображаемое имя msg: Отображаемое имя не может быть пустым. - msg_range: Display name must be 2-30 characters in length. + msg_range: Отображаемое имя должно содержать от 2 до 30 символов. username: label: Имя пользователя caption: Люди могут упоминать вас как "@username". msg: Имя пользователя не может быть пустым. - msg_range: Username must be 2-30 characters in length. - character: 'Must use the character set "a-z", "0-9", "- . _"' + msg_range: Имя пользователя должно содержать от 2 до 30 символов. + character: 'Используйте набор символов "a-z", "0-9", "- . _"' avatar: label: Изображение профиля gravatar: Gravatar @@ -1348,7 +1348,7 @@ ui: change_email_info: >- Мы отправили электронное письмо на этот адрес. Пожалуйста, следуйте инструкциям из письма. email: - label: Email + label: Эл. почта new_email: label: Новый email msg: Новый email не может быть пустым. @@ -1386,27 +1386,27 @@ ui: review: Ваша версия будет отображаться после проверки. sent_success: Отправлено успешно related_question: - title: Related + title: Похожие answers: ответы linked_question: - title: Linked - description: Posts linked to - no_linked_question: No contents linked from this content. + title: Связанные + description: Посты, связанные с + no_linked_question: Из этого контента нет ссылок на другой контент. invite_to_answer: - title: Позвать на помощь + title: Помощь desc: Выберите людей, которые, по вашему мнению, могут знать ответ. invite: Пригласил вас ответить add: Добавить пользователей search: Поиск людей question_detail: action: Действия - created: Created + created: Создано Asked: Спросил(а) asked: спросил(а) update: Изменён - Edited: Edited + Edited: Изменено edit: отредактировал - commented: commented + commented: прокомментировал Views: Просмотрен Follow: Подписаться Following: Подписки @@ -1424,7 +1424,7 @@ ui: title: Ответы score: Оценка newest: Последние - oldest: Oldest + oldest: Сначала старые btn_accept: Принять btn_accepted: Принято write_answer: @@ -1450,12 +1450,12 @@ ui: content: Вы уверены, что хотите открыть заново? list: confirm_btn: Список - title: List this post - content: Are you sure you want to list? + title: Добавить эту запись в список + content: Вы уверены, что хотите добавить в список? unlist: confirm_btn: Убрать из списка - title: Unlist this post - content: Are you sure you want to unlist? + title: Убрать эту запись из списка + content: Вы уверены, что хотите убрать из списка? pin: title: Закрепить сообщение content: Вы уверены, что хотите закрепить глобально? Это сообщение появится вверху всех списков сообщений. @@ -1477,14 +1477,14 @@ ui: save: Сохранить delete: Удалить undelete: Отменить удаление - list: List - unlist: Unlist - unlisted: Unlisted + list: В список + unlist: Убрать из списка + unlisted: Убрано из списка login: Авторизоваться signup: Регистрация logout: Выйти verify: Подтвердить - create: Create + create: Создать approve: Одобрить reject: Отклонить skip: Пропустить @@ -1508,24 +1508,24 @@ ui: system_setting: Настройки системы default: По умолчанию reset: Сбросить - tag: Tag - post_lowercase: post - filter: Filter - ignore: Ignore - submit: Submit - normal: Normal - closed: Closed - deleted: Deleted - deleted_permanently: Deleted permanently - pending: Pending - more: More - view: View - card: Card - compact: Compact - display_below: Display below - always_display: Always display - or: or - back_sites: Back to sites + tag: Тег + post_lowercase: запись + filter: Фильтр + ignore: Игнорировать + submit: Отправить + normal: Обычная + closed: Закрыта + deleted: Удалена + deleted_permanently: Удалена навсегда + pending: В ожидании + more: Еще + view: Вид + card: Карточки + compact: Компактный + display_below: Показывать ниже + always_display: Всегда показывать + or: или + back_sites: Вернуться к сайтам search: title: Результаты поиска keywords: Ключевые слова @@ -1533,7 +1533,7 @@ ui: follow: Подписаться following: Подписка counts: "Результатов: {{count}}" - counts_loading: "... Results" + counts_loading: "... Результаты" more: Ещё sort_btns: relevance: По релевантности @@ -1543,7 +1543,7 @@ ui: more: Больше tips: title: Советы по расширенному поиску - tag: "<1>[tag] search with a tag" + tag: "<1>[tag] поиск по тегу" user: "<1>user:username поиск по автору" answer: "<1>ответов:0 вопросы без ответов" score: "<1>score:3 записи с рейтингом 3+" @@ -1556,18 +1556,18 @@ ui: via: Поделитесь постом через... copied: Скопировано facebook: Поделиться на Facebook - twitter: Share to X + twitter: Поделиться в X cannot_vote_for_self: Вы не можете проголосовать за свой собственный пост. modal_confirm: title: Ошибка... delete_permanently: - title: Delete permanently - content: Are you sure you want to delete permanently? + title: Удалить навсегда + content: Вы уверены, что хотите удалить навсегда? account_result: success: Ваша новая учетная запись подтверждена; вы будете перенаправлены на главную страницу. link: Перейти на главную - oops: Oops! - invalid: The link you used no longer works. + oops: Упс! + invalid: Ссылка, которую вы использовали, больше не работает. confirm_new_email: Ваш адрес электронной почты был обновлен. confirm_new_email_invalid: >- Извините, эта ссылка для подтверждения больше недействительна. Возможно, ваш адрес электронной почты уже был изменен? @@ -1585,14 +1585,14 @@ ui: all_questions: Все вопросы x_questions: "{{ count }} вопросов" x_answers: "{{ count }} ответов" - x_posts: "{{ count }} Posts" + x_posts: "{{ count }} записей" questions: Вопросы answers: Ответы newest: Последние active: Активные - hot: Hot - frequent: Frequent - recommend: Recommend + hot: Популярные + frequent: Частые + recommend: Рекомендованные score: Оценка unanswered: Без ответа modified: изменён @@ -1611,7 +1611,7 @@ ui: reputation: Репутация comments: Комментарии votes: Голоса - badges: Badges + badges: Значки newest: Последние score: Оценки edit_profile: Редактировать профиль @@ -1626,20 +1626,20 @@ ui: top_questions: Топ вопросов stats: Статистика list_empty: Сообщений не найдено.
Возможно, вы хотели бы выбрать другую вкладку? - content_empty: No posts found. + content_empty: Записи не найдены. accepted: Принято answered: отвеченные asked: спросил downvoted: проголосовано против - mod_short: MOD + mod_short: МОД mod_long: Модераторы x_reputation: репутация x_votes: полученные голоса x_answers: ответы x_questions: вопросы - recent_badges: Recent Badges + recent_badges: Недавние значки install: - title: Installation + title: Установка next: Следующий done: Готово config_yaml_error: Не удается создать файл config.yaml. @@ -1668,22 +1668,22 @@ ui: placeholder: /data/answer.db msg: Файл базы данных не может быть пустым. ssl_enabled: - label: Enable SSL + label: Включить SSL ssl_enabled_on: - label: On + label: Да ssl_enabled_off: - label: Off + label: Нет ssl_mode: - label: SSL Mode + label: Режим SSL ssl_root_cert: - placeholder: sslrootcert file path - msg: Path to sslrootcert file cannot be empty + placeholder: путь к файлу sslrootcert + msg: Путь к файлу sslrootcert не может быть пустым ssl_cert: - placeholder: sslcert file path - msg: Path to sslcert file cannot be empty + placeholder: путь к файлу sslcert + msg: Путь к файлу sslcert не может быть пустым ssl_key: - placeholder: sslkey file path - msg: Path to sslkey file cannot be empty + placeholder: путь к файлу sslkey + msg: Путь к файлу sslkey не может быть пустым config_yaml: title: Создайте файл config.yaml label: Файл config.yaml создан. @@ -1716,8 +1716,8 @@ ui: admin_name: label: Имя msg: Имя не может быть пустым. - character: 'Must use the character set "a-z", "0-9", " - . _"' - msg_max_length: Name must be between 2 to 30 characters in length. + character: 'Можно использовать символы из набора "a-z", "0-9", " - . _"' + msg_max_length: Длина имени должна составлять от 2 до 30 символов. admin_password: label: Пароль text: >- @@ -1726,16 +1726,16 @@ ui: msg_min_length: Длина пароля должна составлять не менее 8 символов. msg_max_length: Длина пароля должна составлять не более 32 символов. admin_confirm_password: - label: "Confirm Password" - text: "Please re-enter your password to confirm." - msg: "Confirm password does not match." + label: "Подтверждение пароля" + text: "Пожалуйста, введите пароль повторно для подтверждения." + msg: "Подтверждение пароля не совпадает." admin_email: - label: Email + label: Эл. почта text: Вам понадобится этот адрес электронной почты для входа в систему. msg: empty: Адрес электронной почты не может быть пустым. incorrect: Недопустимый формат e-mail адреса. - ready_title: Your site is ready + ready_title: Ваш сайт готов ready_desc: >- Если вам когда-нибудь захочется изменить дополнительные настройки, посетите <1>раздел администратора; найдите его в меню сайта. good_luck: "Получайте удовольствие и удачи!" @@ -1768,7 +1768,7 @@ ui: questions: Вопросы answers: Ответы users: Пользователи - badges: Badges + badges: Значки flags: Отметить settings: Настройки general: Основные @@ -1777,7 +1777,7 @@ ui: branding: Фирменное оформление legal: Правовая информация write: Написать - terms: Terms + terms: Условия tos: Пользовательское Соглашение privacy: Конфиденциальность seo: SEO @@ -1787,18 +1787,18 @@ ui: privileges: Привилегии plugins: Плагины installed_plugins: Установленные плагины - apperance: Appearance - community: Community - advanced: Advanced - tags: Tags - rules: Rules - policies: Policies - security: Security - files: Files - apikeys: API Keys - intelligence: Intelligence - ai_assistant: AI Assistant - ai_settings: AI Settings + apperance: Внешний вид + community: Сообщество + advanced: Дополнительно + tags: Теги + rules: Правила + policies: Политики + security: Безопасность + files: Файлы + apikeys: API-ключи + intelligence: Интеллект + ai_assistant: ИИ-ассистент + ai_settings: Настройки ИИ mcp: MCP website_welcome: Добро пожаловать на {{site_name}} user_center: @@ -1807,32 +1807,32 @@ ui: login_failed_email_tip: Не удалось войти в систему, пожалуйста, разрешите этому приложению получить доступ к вашей электронной почте, прежде чем повторять попытку. badges: modal: - title: Congratulations - content: You've earned a new badge. - close: Close - confirm: View badges - title: Badges - awarded: Awarded - earned_×: Earned ×{{ number }} - ×_awarded: "{{ number }} awarded" - can_earn_multiple: You can earn this multiple times. - earned: Earned + title: Поздравляем + content: Вы получили новый значок. + close: Закрыть + confirm: Посмотреть значки + title: Значки + awarded: Присвоено + earned_×: Получено ×{{ number }} + ×_awarded: "присвоено: {{ number }}" + can_earn_multiple: Этот значок можно получить несколько раз. + earned: Получено admin: admin_header: title: Администратор dashboard: title: Панель управления - welcome: Welcome to Admin! + welcome: Добро пожаловать в панель администратора! site_statistics: Статистика сайта questions: "Вопросы:" - resolved: "Resolved:" - unanswered: "Unanswered:" + resolved: "Решено:" + unanswered: "Без ответа:" answers: "Ответы:" comments: "Комментарии:" votes: "Голоса:" users: "Пользователи:" flags: "Жалобы:" - reviews: "Reviews:" + reviews: "На проверке:" site_health: Здоровье сайта version: "Версия:" https: "HTTPS:" @@ -1894,21 +1894,21 @@ ui: btn_cancel: Отменить btn_submit: Отправить edit_profile_modal: - title: Edit profile + title: Редактировать профиль form: fields: display_name: - label: Display name - msg_range: Display name must be 2-30 characters in length. + label: Отображаемое имя + msg_range: Отображаемое имя должно содержать от 2 до 30 символов. username: - label: Username - msg_range: Username must be 2-30 characters in length. + label: Имя пользователя + msg_range: Имя пользователя должно содержать от 2 до 30 символов. email: - label: Email - msg_invalid: Invalid Email Address. - edit_success: Edited successfully - btn_cancel: Cancel - btn_submit: Submit + label: Электронная почта + msg_invalid: Неверный адрес электронной почты. + edit_success: Изменено успешно + btn_cancel: Отмена + btn_submit: Отправить user_modal: title: Создание новых пользователей form: @@ -1920,7 +1920,7 @@ ui: msg: "Пожалуйста, введите адрес электронной почты пользователя, по одному на строку." display_name: label: Отображаемое имя - msg: Display name must be 2-30 characters in length. + msg: Отображаемое имя должно содержать от 2 до 30 символов. email: label: Email msg: Некорректный email. @@ -1934,10 +1934,10 @@ ui: name: Имя email: Email reputation: Репутация - created_at: Created time - delete_at: Deleted time - suspend_at: Suspended time - suspend_until: Suspend until + created_at: Время создания + delete_at: Время удаления + suspend_at: Время блокировки + suspend_until: Заблокировать до status: Статус role: Роль action: Действия @@ -1955,7 +1955,7 @@ ui: filter: placeholder: "Фильтровать по имени, user:id" set_new_password: Задать новый пароль - edit_profile: Edit profile + edit_profile: Редактировать профиль change_status: Изменить статус change_role: Изменить роль show_logs: Показать логи @@ -1972,11 +1972,11 @@ ui: suspend_user: title: Заблокировать этого пользователя content: Заблокированный пользователь не сможет войти. - label: How long will the user be suspended for? - forever: Forever + label: На какой срок заблокировать пользователя? + forever: Навсегда questions: page_title: Вопросы - unlisted: Unlisted + unlisted: Скрытый из списка post: Публикация votes: Голоса answers: Ответы @@ -2035,11 +2035,11 @@ ui: msg: Часовой пояс не может быть пустым. text: Выберите город в том же часовом поясе, что и вы. avatar: - label: Default avatar - text: For users without a custom avatar of their own. + label: Аватар по умолчанию + text: Для пользователей без собственного аватара. gravatar_base_url: - label: Gravatar base URL - text: URL of the Gravatar provider's API base. Ignored when empty. + label: Базовый URL Gravatar + text: URL базы API провайдера Gravatar. Игнорируется, если пусто. smtp: page_title: SMTP from_email: @@ -2106,49 +2106,49 @@ ui: label: Условия конфиденциальности text: "Вы можете добавить содержание политики конфиденциальности здесь. Если у вас уже есть документ, размещенный в другом месте, укажите полный URL-адрес здесь." external_content_display: - label: External content - text: "Content includes images, videos, and media embedded from external websites." - always_display: Always display external content - ask_before_display: Ask before displaying external content + label: Внешний контент + text: "Контент включает изображения, видео и медиа, встроенные с внешних сайтов." + always_display: Всегда отображать внешний контент + ask_before_display: Спрашивать перед отображением внешнего контента write: - page_title: Files + page_title: Файлы min_content: - label: Minimum question body length - text: Minimum allowed question body length in characters. + label: Минимальная длина текста вопроса + text: Минимально допустимая длина текста вопроса в символах. restrict_answer: - title: Answer write + title: Написание ответов label: Каждый пользователь может написать только один ответ на каждый вопрос - text: "Turn off to allow users to write multiple answers to the same question, which may cause answers to be unfocused." + text: "Отключите, чтобы разрешить пользователям писать несколько ответов на один вопрос, что может привести к потере фокуса ответов." min_tags: - label: "Minimum tags per question" - text: "Minimum number of tags required in a question." + label: "Минимум тегов на вопрос" + text: "Минимальное число тегов, требуемых в вопросе." recommend_tags: label: Рекомендованные теги - text: "Recommend tags will show in the dropdown list by default." + text: "Рекомендуемые теги по умолчанию будут отображаться в выпадающем списке." msg: - contain_reserved: "recommended tags cannot contain reserved tags" + contain_reserved: "рекомендуемые теги не могут содержать зарезервированные теги" required_tag: - title: Set required tags - label: Set “Recommend tags” as required tags + title: Задать обязательные теги + label: Сделать «рекомендуемые теги» обязательными text: "Каждый новый вопрос должен иметь хотя бы один рекомендуемый тег." reserved_tags: label: Зарезервированные теги - text: "Reserved tags can only be used by moderator." + text: "Зарезервированные теги могут использоваться только модератором." image_size: - label: Max image size (MB) - text: "The maximum image upload size." + label: Макс. размер изображения (МБ) + text: "Максимальный размер загружаемого изображения." attachment_size: - label: Max attachment size (MB) - text: "The maximum attachment files upload size." + label: Макс. размер вложения (МБ) + text: "Максимальный размер загружаемых файлов вложений." image_megapixels: - label: Max image megapixels - text: "Maximum number of megapixels allowed for an image." + label: Макс. число мегапикселей изображения + text: "Максимальное число мегапикселей, допустимое для изображения." image_extensions: - label: Authorized image extensions - text: "A list of file extensions allowed for image display, separate with commas." + label: Разрешенные расширения изображений + text: "Список расширений файлов, разрешенных для отображения изображений, через запятую." attachment_extensions: - label: Authorized attachment extensions - text: "A list of file extensions allowed for upload, separate with commas. WARNING: Allowing uploads may cause security issues." + label: Разрешенные расширения вложений + text: "Список расширений файлов, разрешенных для загрузки, через запятую. ВНИМАНИЕ: разрешение загрузки может привести к проблемам с безопасностью." seo: page_title: SEO permalink: @@ -2165,27 +2165,30 @@ ui: color_scheme: label: Цветовая схема navbar_style: - label: Navbar background style + label: Стиль фона панели навигации primary_color: label: Основной цвет text: Измените цвета, используемые в ваших темах layout: - label: Layout - full_width: Full-width - fixed_width: Fixed-width + label: Макет + full_width: На всю ширину + fixed_width: Фиксированная ширина css_and_html: page_title: CSS и HTML custom_css: label: Пользовательский CSS - text: > + text: >- + Это будет вставлено как <link> head: label: Head - text: > + text: >- + Это будет вставлено перед </head> header: label: Header - text: > + text: >- + Это будет вставлено после <body> footer: label: Нижняя панель @@ -2216,7 +2219,7 @@ ui: text: "Предупреждение: При отключении, вы не сможете войти, если ранее не настроили другой способ входа." installed_plugins: title: Установленные плагины - plugin_link: Plugins extend and expand the functionality. You may find plugins in the <1>Plugin Repository. + plugin_link: Плагины расширяют и дополняют функциональность. Вы можете найти плагины в <1>репозитории плагинов. filter: all: Все active: Активные @@ -2260,94 +2263,94 @@ ui: label: Необходимый уровень репутации text: Выберите количество репутации, необходимое для получения привилегий msg: - should_be_number: the input should be number - number_larger_1: number should be equal or larger than 1 + should_be_number: значение должно быть числом + number_larger_1: число должно быть равно или больше 1 badges: - action: Action - active: Active - activate: Activate - all: All - awards: Awards - deactivate: Deactivate + action: Действие + active: Активные + activate: Активировать + all: Все + awards: Награды + deactivate: Деактивировать filter: - placeholder: Filter by name, badge:id - group: Group - inactive: Inactive - name: Name - show_logs: Show logs - status: Status - title: Badges + placeholder: Фильтр по имени, badge:id + group: Группа + inactive: Неактивные + name: Название + show_logs: Показать логи + status: Статус + title: Значки apikeys: - title: API Keys - add_api_key: Add API Key - desc: Description - scope: Scope - key: Key - created: Created - last_used: Last used + title: API-ключи + add_api_key: Добавить API-ключ + desc: Описание + scope: Область действия + key: Ключ + created: Создан + last_used: Последнее использование add_or_edit_modal: - add_title: Add API Key - edit_title: Edit API Key - description: Description - description_required: Description is required. - scope: Scope - global: Global - read-only: Read-only + add_title: Добавить API-ключ + edit_title: Изменить API-ключ + description: Описание + description_required: Описание обязательно. + scope: Область действия + global: Глобальный + read-only: Только для чтения created_modal: - title: API key created - api_key: API key - description: This key will not be displayed again. Make sure you take a copy before continuing. + title: API-ключ создан + api_key: API-ключ + description: Этот ключ больше не будет показан. Обязательно скопируйте его, прежде чем продолжить. delete_modal: - title: Delete API Key - content: Any applications or scripts using this key will no longer be able to access the API. This is permanent! + title: Удалить API-ключ + content: Любые приложения или скрипты, использующие этот ключ, больше не смогут обращаться к API. Это действие необратимо! ai_settings: enabled: - label: AI enabled - check: Enable AI features - text: The AI model must be configured correctly before it can be used. + label: ИИ включен + check: Включить функции ИИ + text: Перед использованием модель ИИ должна быть корректно настроена. provider: - label: Provider + label: Провайдер api_host: - label: API host - msg: API host is required + label: API-хост + msg: API-хост обязателен api_key: - label: API key - check: Check - check_success: "Connection successful." - msg: API key is required + label: API-ключ + check: Проверить + check_success: "Подключение выполнено успешно." + msg: API-ключ обязателен model: - label: Model - msg: Model is required - add_success: AI settings updated successfully. + label: Модель + msg: Модель обязательна + add_success: Настройки ИИ успешно обновлены. conversations: - topic: Topic - helpful: Helpful - unhelpful: Unhelpful - created: Created - action: Action - empty: No conversations found. + topic: Тема + helpful: Полезный + unhelpful: Бесполезный + created: Создан + action: Действие + empty: Диалоги не найдены. delete_modal: - title: Delete conversation - content: Are you sure you want to delete this conversation? This is permanent! - delete_success: Conversation deleted successfully. + title: Удалить диалог + content: Вы уверены, что хотите удалить этот диалог? Это действие необратимо! + delete_success: Диалог успешно удален. mcp: mcp_server: - label: MCP server - switch: Enabled + label: MCP-сервер + switch: Включен type: - label: Type + label: Тип url: label: URL http_header: - label: HTTP header - text: Please replace {key} with the API Key. + label: HTTP-заголовок + text: Замените {key} на API-ключ. form: optional: (опционально) empty: не может быть пустым invalid: недействителен btn_submit: Сохранить not_found_props: "Требуемое свойство {{ key }} не найдено." - select: Select + select: Выбрать page_review: review: На проверку proposed: предложенный @@ -2359,22 +2362,22 @@ ui: edit_answer: Редактирование ответа edit_tag: Редактирование тега empty: Нет задач для проверки. - approve_revision_tip: Do you approve this revision? - approve_flag_tip: Do you approve this flag? - approve_post_tip: Do you approve this post? - approve_user_tip: Do you approve this user? + approve_revision_tip: Вы одобряете эту правку? + approve_flag_tip: Вы одобряете эту жалобу? + approve_post_tip: Вы одобряете этот пост? + approve_user_tip: Вы одобряете этого пользователя? suggest_edits: Предложенные исправления - flag_post: Flag post - flag_user: Flag user - queued_post: Queued post - queued_user: Queued user - filter_label: Type + flag_post: Пожаловаться на пост + flag_user: Пожаловаться на пользователя + queued_post: Пост в очереди + queued_user: Пользователь в очереди + filter_label: Тип reputation: репутация - flag_post_type: Flagged this post as {{ type }}. - flag_user_type: Flagged this user as {{ type }}. - edit_post: Edit post - list_post: List post - unlist_post: Unlist post + flag_post_type: На этот пост подана жалоба как на {{ type }}. + flag_user_type: На этого пользователя подана жалоба как на {{ type }}. + edit_post: Редактировать пост + list_post: Показать пост в списке + unlist_post: Скрыть пост из списка timeline: undeleted: Восстановлен deleted: Удаленные @@ -2386,21 +2389,21 @@ ui: rollback: откатить edited: отредактированный answered: отвеченные - asked: asked + asked: задал вопрос closed: закрытый reopened: Открыт повторно created: созданный pin: закрепленный unpin: незакреплённые - show: listed - hide: unlisted - title: "History for" + show: показан в списке + hide: скрыт из списка + title: "История" tag_title: "Хронология" - show_votes: "Show votes" + show_votes: "Показать голоса" n_or_a: Недоступно title_for_question: "Хронология" - title_for_answer: "Timeline for answer to {{ title }} by {{ author }}" - title_for_tag: "Timeline for tag" + title_for_answer: "Хронология ответа на {{ title }} от {{ author }}" + title_for_tag: "Хронология тега" datetime: Дата и время type: Тип by: Автор @@ -2420,31 +2423,31 @@ ui: discard_confirm: Вы уверены, что хотите отказаться от своего черновика? messages: post_deleted: Этот пост был удалён. - post_cancel_deleted: This post has been undeleted. + post_cancel_deleted: Этот пост был восстановлен. post_pin: Этот пост был закреплен. post_unpin: Этот пост был откреплен. post_hide_list: Это сообщение было скрыто из списка. post_show_list: Этот пост был показан в списке. post_reopen: Этот пост был вновь открыт. - post_list: This post has been listed. - post_unlist: This post has been unlisted. - post_pending: Your post is awaiting review. This is a preview, it will be visible after it has been approved. - post_closed: This post has been closed. - answer_deleted: This answer has been deleted. - answer_cancel_deleted: This answer has been undeleted. - change_user_role: This user's role has been changed. - user_inactive: This user is already inactive. - user_normal: This user is already normal. - user_suspended: This user has been suspended. - user_deleted: This user has been deleted. - user_added: User has been added successfully. - badge_activated: This badge has been activated. - badge_inactivated: This badge has been inactivated. - users_deleted: These users have been deleted. - posts_deleted: These questions have been deleted. - answers_deleted: These answers have been deleted. - copy: Copy to clipboard - copied: Copied - external_content_warning: External images/media are not displayed. + post_list: Этот пост был показан в списке. + post_unlist: Этот пост был скрыт из списка. + post_pending: Ваш пост ожидает проверки. Это предварительный просмотр, он станет видимым после одобрения. + post_closed: Этот пост был закрыт. + answer_deleted: Этот ответ был удален. + answer_cancel_deleted: Этот ответ был восстановлен. + change_user_role: Роль этого пользователя была изменена. + user_inactive: Этот пользователь уже неактивен. + user_normal: Этот пользователь уже имеет обычный статус. + user_suspended: Этот пользователь был заблокирован. + user_deleted: Этот пользователь был удален. + user_added: Пользователь успешно добавлен. + badge_activated: Этот значок был активирован. + badge_inactivated: Этот значок был деактивирован. + users_deleted: Эти пользователи были удалены. + posts_deleted: Эти вопросы были удалены. + answers_deleted: Эти ответы были удалены. + copy: Копировать в буфер обмена + copied: Скопировано + external_content_warning: Внешние изображения/медиа не отображаются. diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index f16ed9fad..2ee0c1a49 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -2205,7 +2205,7 @@ ui: text: 关闭以阻止任何人通过邮箱创建新账户。 allowed_email_domains: title: 允许的邮箱域 - text: 允许注册账户的邮箱域。每行一个域名。留空时忽略。 + text: 允许用于注册账户的邮箱域。每行一个域名,须包含 @ 符号(例如 @example.com)。留空时忽略。 private: title: 非公开的 label: 需要登录 @@ -2447,4 +2447,3 @@ ui: copied: 已复制 external_content_warning: 外部图像/媒体未显示。 - diff --git a/internal/base/middleware/header.go b/internal/base/middleware/header.go index 717d2ac08..15de54dfc 100644 --- a/internal/base/middleware/header.go +++ b/internal/base/middleware/header.go @@ -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") } diff --git a/internal/base/middleware/header_test.go b/internal/base/middleware/header_test.go new file mode 100644 index 000000000..9f838aebc --- /dev/null +++ b/internal/base/middleware/header_test.go @@ -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) + } +} diff --git a/internal/base/middleware/visit_img_auth.go b/internal/base/middleware/visit_img_auth.go index bfd157a92..b2aea6b80 100644 --- a/internal/base/middleware/visit_img_auth.go +++ b/internal/base/middleware/visit_img_auth.go @@ -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 { diff --git a/internal/base/middleware/visit_img_auth_test.go b/internal/base/middleware/visit_img_auth_test.go new file mode 100644 index 000000000..7d1858ce2 --- /dev/null +++ b/internal/base/middleware/visit_img_auth_test.go @@ -0,0 +1,155 @@ +/* + * 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 ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/entity" + authrepo "github.com/apache/answer/internal/repo/auth" + authservice "github.com/apache/answer/internal/service/auth" + "github.com/apache/answer/internal/service/siteinfo_common" + "github.com/gin-gonic/gin" +) + +type visitAuthTestSiteInfoRepo struct{} + +func (visitAuthTestSiteInfoRepo) SaveByType(context.Context, string, *entity.SiteInfo) error { + return nil +} + +func (visitAuthTestSiteInfoRepo) GetByType(context.Context, string, ...bool) (*entity.SiteInfo, bool, error) { + return &entity.SiteInfo{Content: `{"login_required":true}`}, true, nil +} + +func (visitAuthTestSiteInfoRepo) IsBrandingFileUsed(context.Context, string) (bool, error) { + return false, nil +} + +func TestVisitAuthRejectsRevokedAndSuspendedSessions(t *testing.T) { + ctx := context.Background() + cache, cleanup, err := data.NewCache(&data.CacheConf{}) + if err != nil { + t.Fatalf("create cache: %v", err) + } + t.Cleanup(cleanup) + + repo := authrepo.NewAuthRepo(&data.Data{Cache: cache}) + service := authservice.NewAuthService(repo, nil) + accessToken, visitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{ + UserID: "visit-auth-user", + UserStatus: entity.UserStatusAvailable, + EmailStatus: entity.EmailStatusAvailable, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + + filePath := filepath.Join(t.TempDir(), "private.txt") + if err := os.WriteFile(filePath, []byte("private upload"), 0o600); err != nil { + t.Fatalf("create private upload: %v", err) + } + serve := func(token string) *httptest.ResponseRecorder { + gin.SetMode(gin.TestMode) + engine := gin.New() + siteInfo := siteinfo_common.NewSiteInfoCommonService(visitAuthTestSiteInfoRepo{}) + authMiddleware := NewAuthUserMiddleware(service, siteInfo) + engine.Use(authMiddleware.VisitAuth()) + engine.StaticFile("/uploads/post/private.txt", filePath) + req := httptest.NewRequest(http.MethodGet, "/uploads/post/private.txt", nil) + req.AddCookie(&http.Cookie{Name: constant.UserVisitCookiesCacheKey, Value: token}) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + return recorder + } + + if response := serve(visitToken); response.Code != http.StatusOK { + t.Fatalf("fresh visit token returned %d, want %d", response.Code, http.StatusOK) + } + + service.RemoveUserAllTokens(ctx, "visit-auth-user") + if userInfo, err := service.GetUserCacheInfo(ctx, accessToken); err != nil || userInfo != nil { + t.Fatalf("revoked access token remained valid: userInfo=%v err=%v", userInfo, err) + } + if response := serve(visitToken); response.Code != http.StatusFound || response.Header().Get("Location") != "/403" { + t.Fatalf("revoked visit token returned status=%d location=%q, want 302 /403", response.Code, response.Header().Get("Location")) + } + + _, suspendedVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{ + UserID: "suspended-visit-auth-user", + UserStatus: entity.UserStatusAvailable, + EmailStatus: entity.EmailStatusAvailable, + }) + if err != nil { + t.Fatalf("create suspended-user session: %v", err) + } + if err := service.SetUserStatus(ctx, &entity.UserCacheInfo{ + UserID: "suspended-visit-auth-user", + UserStatus: entity.UserStatusSuspended, + EmailStatus: entity.EmailStatusAvailable, + }); err != nil { + t.Fatalf("suspend user: %v", err) + } + if response := serve(suspendedVisitToken); response.Code != http.StatusFound || response.Header().Get("Location") != "/403" { + t.Fatalf("suspended visit token returned status=%d location=%q, want 302 /403", response.Code, response.Header().Get("Location")) + } +} + +func TestRemoveTokensExceptCurrentUserRevokesOnlyOtherVisitTokens(t *testing.T) { + ctx := context.Background() + cache, cleanup, err := data.NewCache(&data.CacheConf{}) + if err != nil { + t.Fatalf("create cache: %v", err) + } + t.Cleanup(cleanup) + + service := authservice.NewAuthService(authrepo.NewAuthRepo(&data.Data{Cache: cache}), nil) + currentAccessToken, currentVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{ + UserID: "multi-session-user", + UserStatus: entity.UserStatusAvailable, + EmailStatus: entity.EmailStatusAvailable, + }) + if err != nil { + t.Fatalf("create current session: %v", err) + } + _, otherVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{ + UserID: "multi-session-user", + UserStatus: entity.UserStatusAvailable, + EmailStatus: entity.EmailStatusAvailable, + }) + if err != nil { + t.Fatalf("create other session: %v", err) + } + + service.RemoveTokensExceptCurrentUser(ctx, "multi-session-user", currentAccessToken) + if !service.CheckUserVisitToken(ctx, currentVisitToken) { + t.Fatal("current visit token was revoked") + } + if service.CheckUserVisitToken(ctx, otherVisitToken) { + t.Fatal("other visit token remained valid") + } +} diff --git a/internal/base/server/http.go b/internal/base/server/http.go index 8db557440..22e7afdb7 100644 --- a/internal/base/server/http.go +++ b/internal/base/server/http.go @@ -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 diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go index e7495253b..c2fcc8733 100644 --- a/internal/controller/ai_controller.go +++ b/internal/controller/ai_controller.go @@ -324,19 +324,45 @@ func (c *AIController) getPromptByLanguage(language i18n.Language, question stri return c.getDefaultPrompt(language, question) } - return fmt.Sprintf(promptTemplate, question) + return c.adaptPromptToCapabilities(fmt.Sprintf(promptTemplate, question)) } // getDefaultPrompt prompt func (c *AIController) getDefaultPrompt(language i18n.Language, question string) string { + var prompt string switch language { case i18n.LanguageChinese: - return fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) case i18n.LanguageEnglish: - return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) default: - return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) } + return c.adaptPromptToCapabilities(prompt) +} + +// adaptPromptToCapabilities removes instructions for tools the current +// deployment cannot serve, so the model is never prompted to call a missing +// capability. +func (c *AIController) adaptPromptToCapabilities(prompt string) string { + if c.mcpController.SemanticSearchAvailable() { + return prompt + } + return stripSemanticSearchLine(prompt) +} + +// stripSemanticSearchLine drops every prompt line that references the +// semantic_search tool. +func stripSemanticSearchLine(prompt string) string { + lines := strings.Split(prompt, "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.Contains(line, semanticSearchToolName) { + continue + } + kept = append(kept, line) + } + return strings.Join(kept, "\n") } // initializeConversationContext @@ -699,10 +725,25 @@ func (c *AIController) sendErrorResponse(w http.ResponseWriter, id, model, error sendStreamData(w, errorResponse) } -// getMCPTools +// semanticSearchToolName is the MCP tool backed by the optional VectorSearch +// plugin. It must not be advertised when no such plugin is enabled. +const semanticSearchToolName = "semantic_search" + +// getMCPTools builds the tool list advertised to the model. The +// semantic_search tool is omitted when no VectorSearch plugin is enabled, +// otherwise the model can select a capability that always fails. func (c *AIController) getMCPTools() []openai.Tool { - openaiTools := make([]openai.Tool, 0) - for _, mcpTool := range mcp_tools.MCPToolsList { + return c.buildOpenAITools(mcp_tools.MCPToolsList, c.mcpController.SemanticSearchAvailable()) +} + +// buildOpenAITools converts MCP tools into OpenAI tool definitions, optionally +// excluding the semantic_search tool. +func (c *AIController) buildOpenAITools(tools []mcp.Tool, includeSemanticSearch bool) []openai.Tool { + openaiTools := make([]openai.Tool, 0, len(tools)) + for _, mcpTool := range tools { + if !includeSemanticSearch && mcpTool.Name == semanticSearchToolName { + continue + } openaiTool := c.convertMCPToolToOpenAI(mcpTool) openaiTools = append(openaiTools, openaiTool) } diff --git a/internal/controller/ai_tools_test.go b/internal/controller/ai_tools_test.go new file mode 100644 index 000000000..363ca04ba --- /dev/null +++ b/internal/controller/ai_tools_test.go @@ -0,0 +1,90 @@ +/* + * 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 ( + "strings" + "testing" + + "github.com/apache/answer/internal/schema/mcp_tools" + "github.com/apache/answer/internal/service/embedding" +) + +func toolNames(c *AIController, includeSemanticSearch bool) map[string]bool { + tools := c.buildOpenAITools(mcp_tools.MCPToolsList, includeSemanticSearch) + names := make(map[string]bool, len(tools)) + for _, t := range tools { + names[t.Function.Name] = true + } + return names +} + +func TestBuildOpenAIToolsIncludesSemanticSearch(t *testing.T) { + c := &AIController{} + names := toolNames(c, true) + if !names[semanticSearchToolName] { + t.Fatalf("semantic_search should be advertised when a vector search plugin is available: %v", names) + } + if len(names) != len(mcp_tools.MCPToolsList) { + t.Fatalf("expect %d tools, got %d", len(mcp_tools.MCPToolsList), len(names)) + } +} + +func TestBuildOpenAIToolsExcludesSemanticSearch(t *testing.T) { + c := &AIController{} + names := toolNames(c, false) + if names[semanticSearchToolName] { + t.Fatalf("semantic_search must not be advertised without a vector search plugin") + } + if len(names) != len(mcp_tools.MCPToolsList)-1 { + t.Fatalf("expect %d tools, got %d", len(mcp_tools.MCPToolsList)-1, len(names)) + } + if !names["get_questions"] || !names["get_user"] { + t.Fatalf("other MCP tools must remain advertised: %v", names) + } +} + +func TestStripSemanticSearchLine(t *testing.T) { + prompt := "You are an assistant.\n- get_questions: search questions\n- semantic_search: search by meaning\n- get_user: search users\n" + got := stripSemanticSearchLine(prompt) + if strings.Contains(got, "semantic_search") { + t.Fatalf("semantic_search line not stripped: %q", got) + } + if !strings.Contains(got, "get_questions") || !strings.Contains(got, "get_user") { + t.Fatalf("unrelated lines were dropped: %q", got) + } + if !strings.HasPrefix(got, "You are an assistant.\n") { + t.Fatalf("leading lines must be kept: %q", got) + } +} + +func TestAdaptPromptToCapabilitiesStripsWhenUnavailable(t *testing.T) { + // In tests no VectorSearch plugin is registered, so semantic search is + // unavailable and the prompt must be adapted. + c := &AIController{mcpController: &MCPController{embeddingService: &embedding.EmbeddingService{}}} + prompt := "intro\n- semantic_search: search by meaning\noutro\n" + got := c.adaptPromptToCapabilities(prompt) + if strings.Contains(got, "semantic_search") { + t.Fatalf("expected semantic_search line removed: %q", got) + } + if !strings.Contains(got, "intro") || !strings.Contains(got, "outro") { + t.Fatalf("other content must be preserved: %q", got) + } +} diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go index 6e16c6a85..58e55c118 100644 --- a/internal/controller/answer_controller.go +++ b/internal/controller/answer_controller.go @@ -208,6 +208,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) { }() req.QuestionID = uid.DeShortID(req.QuestionID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{ permission.AnswerEdit, @@ -220,7 +221,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) { } linkUrlLimitUser := canList[2] - isAdmin := middleware.GetUserIsAdminModerator(ctx) + isAdmin := req.IsAdminModerator if !isAdmin || !linkUrlLimitUser { captchaPass := ac.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionAnswer, req.UserID, req.CaptchaID, req.CaptchaCode) if !captchaPass { diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index b9beead94..4bdd8ac21 100644 --- a/internal/controller/comment_controller.go +++ b/internal/controller/comment_controller.go @@ -88,6 +88,7 @@ func (cc *CommentController) AddComment(ctx *gin.Context) { }() req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{ permission.CommentAdd, @@ -100,7 +101,7 @@ func (cc *CommentController) AddComment(ctx *gin.Context) { return } linkUrlLimitUser := canList[3] - isAdmin := middleware.GetUserIsAdminModerator(ctx) + isAdmin := req.IsAdminModerator if !isAdmin || !linkUrlLimitUser { captchaPass := cc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionComment, req.UserID, req.CaptchaID, req.CaptchaCode) if !captchaPass { @@ -280,7 +281,11 @@ func (cc *CommentController) GetCommentPersonalWithPage(ctx *gin.Context) { return } - req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + if len(req.Username) == 0 { + req.UserID = req.LoginUserID + } + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) resp, err := cc.commentService.GetCommentPersonalWithPage(ctx, req) handler.HandleResponse(ctx, err, resp) diff --git a/internal/controller/mcp_controller.go b/internal/controller/mcp_controller.go index e24c1a546..eebb8baf0 100644 --- a/internal/controller/mcp_controller.go +++ b/internal/controller/mcp_controller.go @@ -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 { @@ -488,3 +516,10 @@ func (c *MCPController) MCPSemanticSearchHandler() func(ctx context.Context, req return mcp.NewToolResultText(string(data)), nil } } + +// SemanticSearchAvailable reports whether a VectorSearch plugin is currently +// enabled, so the AI chat can omit the semantic_search tool entirely instead +// of letting the model call into a missing capability. +func (c *MCPController) SemanticSearchAvailable() bool { + return c.embeddingService.Available() +} diff --git a/internal/controller/mcp_controller_test.go b/internal/controller/mcp_controller_test.go new file mode 100644 index 000000000..09f75a6a3 --- /dev/null +++ b/internal/controller/mcp_controller_test.go @@ -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) + } + }) + } +} diff --git a/internal/controller/meta_controller.go b/internal/controller/meta_controller.go index 624daf093..2bce5eb15 100644 --- a/internal/controller/meta_controller.go +++ b/internal/controller/meta_controller.go @@ -57,6 +57,7 @@ func (mc *MetaController) AddOrUpdateReaction(ctx *gin.Context) { } req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) resp, err := mc.metaService.AddOrUpdateReaction(ctx, req) handler.HandleResponse(ctx, err, resp) @@ -78,6 +79,7 @@ func (mc *MetaController) GetReaction(ctx *gin.Context) { } req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) resp, err := mc.metaService.GetReactionByObjectId(ctx, req) handler.HandleResponse(ctx, err, resp) diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 05ad319ab..8294cdcbd 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -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) } @@ -809,7 +832,17 @@ func (qc *QuestionController) UpdateQuestionInviteUser(ctx *gin.Context) { // @Router /answer/api/v1/question/similar [get] func (qc *QuestionController) GetSimilarQuestions(ctx *gin.Context) { title := ctx.Query("title") - resp, err := qc.questionService.GetQuestionsByTitle(ctx, title) + userID := middleware.GetLoginUserIDFromContext(ctx) + canReopen, err := qc.rankService.CheckOperationPermission(ctx, userID, permission.QuestionReopen, "") + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + per := schema.QuestionPermission{ + IsAdminModerator: middleware.GetUserIsAdminModerator(ctx), + CanReopen: canReopen, + } + resp, err := qc.questionService.GetQuestionsByTitle(ctx, title, userID, per) handler.HandleResponse(ctx, err, resp) } @@ -989,6 +1022,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 { diff --git a/internal/controller/question_controller_test.go b/internal/controller/question_controller_test.go new file mode 100644 index 000000000..35d75f41d --- /dev/null +++ b/internal/controller/question_controller_test.go @@ -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) + } + }) + } +} diff --git a/internal/controller/report_controller.go b/internal/controller/report_controller.go index 13b4c0953..53b298edc 100644 --- a/internal/controller/report_controller.go +++ b/internal/controller/report_controller.go @@ -73,7 +73,8 @@ func (rc *ReportController) AddReport(ctx *gin.Context) { } req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) - isAdmin := middleware.GetUserIsAdminModerator(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) + isAdmin := req.IsAdminModerator if !isAdmin { captchaPass := rc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionReport, req.UserID, req.CaptchaID, req.CaptchaCode) if !captchaPass { diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 31cc5152a..0f2f5b68b 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -20,6 +20,7 @@ package controller import ( + "bytes" "encoding/json" "fmt" "html/template" @@ -50,13 +51,17 @@ import ( "github.com/apache/answer/ui" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/log" + "golang.org/x/net/html" ) var SiteUrl = "" type TemplateController struct { - scriptPath []string - cssPath string + scriptPath []string + // cssPath lists every stylesheet the frontend build emits, in document + // order; a build that emits more than one entry stylesheet needs all of + // them, not just the first, or server-rendered pages come back unstyled. + cssPath []string templateRenderController *templaterender.TemplateRenderController siteInfoService siteinfo_common.SiteInfoCommonService eventQueueService eventqueue.Service @@ -83,24 +88,64 @@ func NewTemplateController( questionService: questionService, } } -func GetStyle() (script []string, css string) { +func GetStyle() (script []string, css []string) { file, err := ui.Build.ReadFile("build/index.html") if err != nil { return } - scriptRegexp := regexp.MustCompile(``) - scriptData := scriptRegexp.FindAllStringSubmatch(string(file), -1) - for _, s := range scriptData { - if len(s) == 2 { - script = append(script, s[1]) + + // Script and stylesheet tags are read from the parsed document, so + // attribute order, attribute set (module vs classic scripts), and + // quoting do not matter. That shape has already changed once; a + // bundler change that breaks it now fails the guarding test instead + // of silently shipping pages with no JS or CSS. + doc, err := html.Parse(bytes.NewReader(file)) + if err != nil { + return + } + + attr := func(n *html.Node, key string) (string, bool) { + for _, a := range n.Attr { + if a.Key == key { + return a.Val, true + } } + return "", false + } + isStylesheet := func(n *html.Node) bool { + rel, ok := attr(n, "rel") + if !ok { + return false + } + for tok := range strings.FieldsSeq(rel) { + if strings.EqualFold(tok, "stylesheet") { + return true + } + } + return false } - cssRegexp := regexp.MustCompile(``) - cssListData := cssRegexp.FindStringSubmatch(string(file)) - if len(cssListData) == 2 { - css = cssListData[1] + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode { + switch n.Data { + case "script": + if src, ok := attr(n, "src"); ok && src != "" { + script = append(script, src) + } + case "link": + if isStylesheet(n) { + if href, ok := attr(n, "href"); ok && href != "" { + css = append(css, href) + } + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } } + walk(doc) return } func (tc *TemplateController) SiteInfo(ctx *gin.Context) *schema.TemplateSiteInfoResp { @@ -560,7 +605,7 @@ func (tc *TemplateController) Page404(ctx *gin.Context) { func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteInfo *schema.TemplateSiteInfoResp, data gin.H) { prefix := "" - cssPath := "" + cssPath := make([]string, len(tc.cssPath)) scriptPath := make([]string, len(tc.scriptPath)) _ = plugin.CallCDN(func(fn plugin.CDN) error { @@ -572,7 +617,9 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI if prefix[len(prefix)-1:] == "/" { prefix = strings.TrimSuffix(prefix, "/") } - cssPath = prefix + tc.cssPath + for i, path := range tc.cssPath { + cssPath[i] = prefix + path + } for i, path := range tc.scriptPath { scriptPath[i] = prefix + path } diff --git a/internal/controller/template_controller_test.go b/internal/controller/template_controller_test.go new file mode 100644 index 000000000..74c0db0a6 --- /dev/null +++ b/internal/controller/template_controller_test.go @@ -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 controller + +import ( + "strings" + "testing" + + "github.com/apache/answer/ui" + "github.com/stretchr/testify/require" +) + +// GetStyle scrapes the script and stylesheet paths out of the built +// index.html and every server-rendered page reuses them. The scrape is +// coupled to the exact attribute order and attribute set that the frontend +// build tool writes into those tags, and nothing in the system reports a +// mismatch: the frontend build still succeeds, the dev server still works, +// the binary still compiles, and the server-rendered pages simply come back +// with no script tags and no stylesheet. +// +// Assert the coupling directly so a change to the emitted tag shape fails +// here instead of shipping. +func TestGetStyleResolvesBuiltAssets(t *testing.T) { + const builtIndexPath = "build/index.html" + + raw, err := ui.Build.ReadFile(builtIndexPath) + if err != nil { + t.Skipf("no frontend build embedded at %s; build the frontend and re-run: %v", builtIndexPath, err) + } + + scripts, css := GetStyle() + + require.NotEmpty(t, scripts, + "no script sources parsed out of %s; server-rendered pages would load without any JavaScript", builtIndexPath) + for i, src := range scripts { + require.NotEmpty(t, src, "script source %d parsed out of %s is empty", i, builtIndexPath) + } + + require.NotEmpty(t, css, + "no stylesheet href parsed out of %s; server-rendered pages would load unstyled", builtIndexPath) + for i, href := range css { + require.NotEmpty(t, href, + "stylesheet href %d parsed out of %s is empty; server-rendered pages would load unstyled", i, builtIndexPath) + } + + // Finding every stylesheet matters as much as finding one. The build emits + // more than a single entry stylesheet, and a parser that stopped at the + // first one would still satisfy every assertion above while half the page's + // CSS silently stopped loading. That regression has happened once already. + // + // Count them again by a deliberately different and cruder method than the + // parser uses, so the two have to agree. It is a lower bound: a build that + // quotes attributes differently drives this to zero and the comparison + // simply stops constraining, which is why it supplements the assertions + // above rather than replacing them. + declared := strings.Count(string(raw), `rel="stylesheet"`) + require.GreaterOrEqual(t, len(css), declared, + "%s declares at least %d stylesheets but only %d were parsed out of it; "+ + "server-rendered pages would load missing part of their CSS", + builtIndexPath, declared, len(css)) +} diff --git a/internal/controller/vote_controller.go b/internal/controller/vote_controller.go index 302796677..10ce42a77 100644 --- a/internal/controller/vote_controller.go +++ b/internal/controller/vote_controller.go @@ -72,6 +72,7 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) { } req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true) if err != nil { @@ -85,7 +86,7 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) { return } - isAdmin := middleware.GetUserIsAdminModerator(ctx) + isAdmin := req.IsAdminModerator if !isAdmin { captchaPass := vc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionVote, req.UserID, req.CaptchaID, req.CaptchaCode) if !captchaPass { @@ -126,7 +127,8 @@ func (vc *VoteController) VoteDown(ctx *gin.Context) { } req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) - isAdmin := middleware.GetUserIsAdminModerator(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) + isAdmin := req.IsAdminModerator can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false) if err != nil { diff --git a/internal/entity/answer_entity.go b/internal/entity/answer_entity.go index 4c9436ecb..0e93aa97f 100644 --- a/internal/entity/answer_entity.go +++ b/internal/entity/answer_entity.go @@ -70,6 +70,7 @@ type PersonalAnswerPageQueryCond struct { UserID string Order string ShowPending bool + ShowHidden bool } // TableName answer table name diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 59fbb7bea..360f688af 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -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 { diff --git a/internal/migrations/v35.go b/internal/migrations/v35.go new file mode 100644 index 000000000..f63037cc9 --- /dev/null +++ b/internal/migrations/v35.go @@ -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 +} diff --git a/internal/migrations/v35_test.go b/internal/migrations/v35_test.go new file mode 100644 index 000000000..7c065e933 --- /dev/null +++ b/internal/migrations/v35_test.go @@ -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) +} diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go index 42e3494a8..615d8dfb1 100644 --- a/internal/repo/answer/answer_repo.go +++ b/internal/repo/answer/answer_repo.go @@ -394,20 +394,24 @@ func (ar *answerRepo) GetPersonalAnswerPage(ctx context.Context, req *entity.Per UserID: req.UserID, } session := ar.data.DB.Context(ctx) + if !req.ShowHidden { + session = session.Join("INNER", "question", "answer.question_id = question.id"). + And("question.show = ?", entity.QuestionShow) + } switch req.Order { case entity.AnswerSearchOrderByTime: - session = session.OrderBy("created_at desc") + session = session.OrderBy("answer.created_at desc") case entity.AnswerSearchOrderByTimeAsc: - session = session.OrderBy("created_at asc") + session = session.OrderBy("answer.created_at asc") case entity.AnswerSearchOrderByVote: - session = session.OrderBy("vote_count desc") + session = session.OrderBy("answer.vote_count desc") default: - session = session.OrderBy("adopted desc,vote_count desc,created_at asc") + session = session.OrderBy("answer.adopted desc,answer.vote_count desc,answer.created_at asc") } if req.ShowPending { - session = session.And("status != ?", entity.AnswerStatusDeleted) + session = session.And("answer.status != ?", entity.AnswerStatusDeleted) } else { - session = session.And("status = ?", entity.AnswerStatusAvailable) + session = session.And("answer.status = ?", entity.AnswerStatusAvailable) } resp = make([]*entity.Answer, 0) total, err = pager.Help(req.Page, req.PageSize, &resp, cond, session) diff --git a/internal/repo/auth/auth.go b/internal/repo/auth/auth.go index 597352b23..7c957d784 100644 --- a/internal/repo/auth/auth.go +++ b/internal/repo/auth/auth.go @@ -224,6 +224,14 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainT if token == remainToken { continue } + userInfo, err := ar.GetUserCacheInfo(ctx, token) + if err != nil { + log.Error(err) + } else if userInfo != nil && len(userInfo.VisitToken) > 0 { + if err := ar.RemoveUserVisitCacheInfo(ctx, userInfo.VisitToken); err != nil { + log.Error(err) + } + } if err := ar.RemoveUserCacheInfo(ctx, token); err != nil { log.Error(err) } else { @@ -233,6 +241,14 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainT if err := ar.RemoveUserStatus(ctx, userID); err != nil { log.Error(err) } + if remainToken != "" { + mapping = map[string]bool{remainToken: true} + content, _ := json.Marshal(mapping) + if err := ar.data.Cache.SetString(ctx, key, string(content), constant.UserTokenCacheTime); err != nil { + log.Error(err) + } + return + } if err := ar.data.Cache.Del(ctx, key); err != nil { log.Error(err) } diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 3449efb8b..379b43805 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -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": diff --git a/internal/repo/repo_test/personal_answer_visibility_test.go b/internal/repo/repo_test/personal_answer_visibility_test.go new file mode 100644 index 000000000..f50105aad --- /dev/null +++ b/internal/repo/repo_test/personal_answer_visibility_test.go @@ -0,0 +1,89 @@ +/* + * 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/answer" + "github.com/apache/answer/internal/repo/question" + "github.com/apache/answer/internal/repo/unique" + "github.com/stretchr/testify/require" +) + +func TestPersonalAnswerPageRespectsHiddenQuestionVisibility(t *testing.T) { + ctx := context.Background() + questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource)) + answerRepo := answer.NewAnswerRepo(testDataSource, nil, nil, nil) + + newQuestion := func(title string, show int) *entity.Question { + q := &entity.Question{ + UserID: "personal-answer-question-owner", + Title: title, + OriginalText: title, + ParsedText: title, + Status: entity.QuestionStatusAvailable, + Show: show, + } + require.NoError(t, questionRepo.AddQuestion(ctx, q)) + return q + } + + visibleQuestion := newQuestion("visible question", entity.QuestionShow) + hiddenQuestion := newQuestion("hidden question", entity.QuestionHide) + answers := []*entity.Answer{ + {QuestionID: visibleQuestion.ID, UserID: "personal-answer-author", OriginalText: "visible answer", ParsedText: "visible answer", Status: entity.AnswerStatusAvailable}, + {QuestionID: hiddenQuestion.ID, UserID: "personal-answer-author", OriginalText: "hidden answer", ParsedText: "hidden answer", Status: entity.AnswerStatusAvailable}, + } + for _, item := range answers { + _, err := testDataSource.DB.Context(ctx).Insert(item) + require.NoError(t, err) + } + t.Cleanup(func() { + for _, item := range answers { + _, _ = testDataSource.DB.Context(ctx).ID(item.ID).Delete(&entity.Answer{}) + } + for _, item := range []*entity.Question{visibleQuestion, hiddenQuestion} { + _, _ = testDataSource.DB.Context(ctx).ID(item.ID).Delete(&entity.Question{}) + } + }) + + publicAnswers, publicTotal, err := answerRepo.GetPersonalAnswerPage(ctx, &entity.PersonalAnswerPageQueryCond{ + Page: 1, + PageSize: 20, + UserID: "personal-answer-author", + }) + require.NoError(t, err) + require.Equal(t, int64(1), publicTotal) + require.Len(t, publicAnswers, 1) + require.Equal(t, visibleQuestion.ID, publicAnswers[0].QuestionID) + + ownerAnswers, ownerTotal, err := answerRepo.GetPersonalAnswerPage(ctx, &entity.PersonalAnswerPageQueryCond{ + Page: 1, + PageSize: 20, + UserID: "personal-answer-author", + ShowHidden: true, + }) + require.NoError(t, err) + require.Equal(t, int64(2), ownerTotal) + require.Len(t, ownerAnswers, 2) +} diff --git a/internal/repo/repo_test/question_link_security_test.go b/internal/repo/repo_test/question_link_security_test.go new file mode 100644 index 000000000..3c38448b5 --- /dev/null +++ b/internal/repo/repo_test/question_link_security_test.go @@ -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) +} diff --git a/internal/repo/tag_common/tag_common_repo.go b/internal/repo/tag_common/tag_common_repo.go index c4762b0ca..73c338cc0 100644 --- a/internal/repo/tag_common/tag_common_repo.go +++ b/internal/repo/tag_common/tag_common_repo.go @@ -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)) +} diff --git a/internal/repo/tag_common/tagsearch_test.go b/internal/repo/tag_common/tagsearch_test.go new file mode 100644 index 000000000..ea2182b91 --- /dev/null +++ b/internal/repo/tag_common/tagsearch_test.go @@ -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") + } + } +} diff --git a/internal/router/static_router.go b/internal/router/static_router.go index a6c80fc04..e16002cfc 100644 --- a/internal/router/static_router.go +++ b/internal/router/static_router.go @@ -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 +} diff --git a/internal/router/static_router_test.go b/internal/router/static_router_test.go new file mode 100644 index 000000000..b123c95db --- /dev/null +++ b/internal/router/static_router_test.go @@ -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) + } + } +} diff --git a/internal/schema/answer_schema.go b/internal/schema/answer_schema.go index bf80c56ec..e81b8630c 100644 --- a/internal/schema/answer_schema.go +++ b/internal/schema/answer_schema.go @@ -47,17 +47,18 @@ const ( ) type AnswerAddReq struct { - QuestionID string `json:"question_id"` - Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"` - HTML string `json:"-"` - UserID string `json:"-"` - CanEdit bool `json:"-"` - CanDelete bool `json:"-"` - CanRecover bool `json:"-"` - CaptchaID string `json:"captcha_id"` - CaptchaCode string `json:"captcha_code"` - IP string `json:"-"` - UserAgent string `json:"-"` + QuestionID string `json:"question_id"` + Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"` + HTML string `json:"-"` + UserID string `json:"-"` + IsAdminModerator bool `json:"-"` + CanEdit bool `json:"-"` + CanDelete bool `json:"-"` + CanRecover bool `json:"-"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` + IP string `json:"-"` + UserAgent string `json:"-"` } func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err error) { diff --git a/internal/schema/comment_schema.go b/internal/schema/comment_schema.go index a9c8a21a3..0fe5ee00d 100644 --- a/internal/schema/comment_schema.go +++ b/internal/schema/comment_schema.go @@ -44,7 +44,8 @@ type AddCommentReq struct { CaptchaCode string `json:"captcha_code"` // user id - UserID string `json:"-"` + UserID string `json:"-"` + IsAdminModerator bool `json:"-"` // whether user can add it CanAdd bool `json:"-"` // whether user can edit it @@ -232,7 +233,9 @@ type GetCommentPersonalWithPageReq struct { // username Username string `validate:"omitempty,gt=0,lte=100" form:"username"` // user id - UserID string `json:"-"` + UserID string `json:"-"` + LoginUserID string `json:"-"` + IsAdminModerator bool `json:"-"` } // GetCommentPersonalWithPageResp comment response diff --git a/internal/schema/email_template.go b/internal/schema/email_template.go index d7e4b929a..e28cf90d8 100644 --- a/internal/schema/email_template.go +++ b/internal/schema/email_template.go @@ -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 diff --git a/internal/schema/email_template_test.go b/internal/schema/email_template_test.go new file mode 100644 index 000000000..8a11738da --- /dev/null +++ b/internal/schema/email_template_test.go @@ -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") + } +} diff --git a/internal/schema/meta_schema.go b/internal/schema/meta_schema.go index e5a072529..64362879a 100644 --- a/internal/schema/meta_schema.go +++ b/internal/schema/meta_schema.go @@ -22,15 +22,17 @@ package schema import "slices" type UpdateReactionReq struct { - ObjectID string `validate:"required" json:"object_id"` - Emoji string `validate:"required,oneof=heart smile frown" json:"emoji"` - Reaction string `validate:"required,oneof=activate deactivate" json:"reaction"` - UserID string `json:"-"` + ObjectID string `validate:"required" json:"object_id"` + Emoji string `validate:"required,oneof=heart smile frown" json:"emoji"` + Reaction string `validate:"required,oneof=activate deactivate" json:"reaction"` + UserID string `json:"-"` + IsAdminModerator bool `json:"-"` } type GetReactionReq struct { - ObjectID string `validate:"required" form:"object_id"` - UserID string `json:"-"` + ObjectID string `validate:"required" form:"object_id"` + UserID string `json:"-"` + IsAdminModerator bool `json:"-"` } // ReactionsSummaryMeta reactions summary meta diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 4c2313852..4c6d2ead7 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -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 { diff --git a/internal/schema/report_schema.go b/internal/schema/report_schema.go index 1f702df47..3c1e33256 100644 --- a/internal/schema/report_schema.go +++ b/internal/schema/report_schema.go @@ -28,9 +28,10 @@ type AddReportReq struct { // report content Content string `validate:"omitempty,gt=0,lte=500" json:"content"` // user id - UserID string `json:"-"` - CaptchaID string `json:"captcha_id"` // captcha_id - CaptchaCode string `json:"captcha_code"` + UserID string `json:"-"` + IsAdminModerator bool `json:"-"` + CaptchaID string `json:"captcha_id"` // captcha_id + CaptchaCode string `json:"captcha_code"` } // GetReportListReq get report list all request diff --git a/internal/schema/vote_schema.go b/internal/schema/vote_schema.go index e82adcc2f..15a547941 100644 --- a/internal/schema/vote_schema.go +++ b/internal/schema/vote_schema.go @@ -20,11 +20,12 @@ package schema type VoteReq struct { - ObjectID string `validate:"required" json:"object_id"` - IsCancel bool `validate:"omitempty" json:"is_cancel"` - CaptchaID string `json:"captcha_id"` - CaptchaCode string `json:"captcha_code"` - UserID string `json:"-"` + ObjectID string `validate:"required" json:"object_id"` + IsCancel bool `validate:"omitempty" json:"is_cancel"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` + UserID string `json:"-"` + IsAdminModerator bool `json:"-"` } type VoteResp struct { diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go index 7d2751059..ac86b7ab6 100644 --- a/internal/service/auth/auth.go +++ b/internal/service/auth/auth.go @@ -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 @@ -103,13 +102,14 @@ func (as *AuthService) SetUserCacheInfo(ctx context.Context, userInfo *entity.Us func (as *AuthService) CheckUserVisitToken(ctx context.Context, visitToken string) bool { accessToken, err := as.authRepo.GetUserVisitCacheInfo(ctx, visitToken) - if err != nil { + if err != nil || len(accessToken) == 0 { return false } - if len(accessToken) == 0 { + userInfo, err := as.GetUserCacheInfo(ctx, accessToken) + if err != nil || userInfo == nil { return false } - return true + return userInfo.EmailStatus == entity.EmailStatusAvailable && userInfo.UserStatus == entity.UserStatusAvailable } func (as *AuthService) SetUserStatus(ctx context.Context, userInfo *entity.UserCacheInfo) (err error) { @@ -198,9 +198,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 } diff --git a/internal/service/auth/auth_test.go b/internal/service/auth/auth_test.go new file mode 100644 index 000000000..17cbd0010 --- /dev/null +++ b/internal/service/auth/auth_test.go @@ -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") } diff --git a/internal/service/auth/user_center_status_test.go b/internal/service/auth/user_center_status_test.go new file mode 100644 index 000000000..df8b379e3 --- /dev/null +++ b/internal/service/auth/user_center_status_test.go @@ -0,0 +1,111 @@ +/* + * 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_test + +import ( + "context" + "fmt" + "testing" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/entity" + authrepo "github.com/apache/answer/internal/repo/auth" + authservice "github.com/apache/answer/internal/service/auth" + "github.com/apache/answer/plugin" +) + +func TestGetUserCacheInfoChecksUserCenterStatus(t *testing.T) { + cache, cleanup, err := data.NewCache(&data.CacheConf{}) + if err != nil { + t.Fatalf("create cache: %v", err) + } + t.Cleanup(cleanup) + + previousCallUserCenter := plugin.CallUserCenter + testUserCenter := &authTestUserCenter{} + plugin.CallUserCenter = plugin.CallFn[plugin.UserCenter](func(fn plugin.Caller[plugin.UserCenter]) error { + return fn(testUserCenter) + }) + t.Cleanup(func() { plugin.CallUserCenter = previousCallUserCenter }) + + service := authservice.NewAuthService(authrepo.NewAuthRepo(&data.Data{Cache: cache}), nil) + for _, expectedStatus := range []plugin.UserStatus{plugin.UserStatusSuspended, plugin.UserStatusDeleted} { + t.Run(fmt.Sprintf("status_%d", expectedStatus), func(t *testing.T) { + testUserCenter.status = expectedStatus + testUserCenter.externalID = "" + accessToken, _, err := service.SetUserCacheInfo(context.Background(), &entity.UserCacheInfo{ + UserID: "user-center-user", + UserStatus: entity.UserStatusAvailable, + EmailStatus: entity.EmailStatusAvailable, + ExternalID: "central-user-1001", + }) + if err != nil { + t.Fatalf("cache user session: %v", err) + } + + userInfo, err := service.GetUserCacheInfo(context.Background(), accessToken) + if err != nil { + t.Fatalf("get user cache info: %v", err) + } + if userInfo.UserStatus != int(expectedStatus) { + t.Fatalf("user status = %d, want %d", userInfo.UserStatus, expectedStatus) + } + if testUserCenter.externalID != "central-user-1001" { + t.Fatalf("user center checked external ID = %q, want %q", testUserCenter.externalID, "central-user-1001") + } + }) + } +} + +type authTestUserCenter struct { + externalID string + status plugin.UserStatus +} + +func (*authTestUserCenter) Info() plugin.Info { return plugin.Info{SlugName: "auth-test-user-center"} } + +func (*authTestUserCenter) Description() plugin.UserCenterDesc { return plugin.UserCenterDesc{} } + +func (*authTestUserCenter) ControlCenterItems() []plugin.ControlCenter { return nil } + +func (*authTestUserCenter) LoginCallback(*plugin.GinContext) (*plugin.UserCenterBasicUserInfo, error) { + return nil, nil +} + +func (*authTestUserCenter) SignUpCallback(*plugin.GinContext) (*plugin.UserCenterBasicUserInfo, error) { + return nil, nil +} + +func (*authTestUserCenter) UserInfo(string) (*plugin.UserCenterBasicUserInfo, error) { return nil, nil } + +func (u *authTestUserCenter) UserStatus(externalID string) plugin.UserStatus { + u.externalID = externalID + return u.status +} + +func (*authTestUserCenter) UserList([]string) ([]*plugin.UserCenterBasicUserInfo, error) { + return nil, nil +} + +func (*authTestUserCenter) UserSettings(string) (*plugin.SettingInfo, error) { return nil, nil } + +func (*authTestUserCenter) PersonalBranding(string) []*plugin.PersonalBranding { return nil } + +func (*authTestUserCenter) AfterLogin(string, string) {} diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index 0decd5847..0cbf20282 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -144,6 +144,9 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment if objInfo.IsDeleted() { return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted) } + if err := objInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil { + return nil, err + } objInfo.ObjectID = uid.DeShortID(objInfo.ObjectID) objInfo.QuestionID = uid.DeShortID(objInfo.QuestionID) objInfo.AnswerID = uid.DeShortID(objInfo.AnswerID) @@ -539,32 +542,40 @@ func (cs *CommentService) GetCommentPersonalWithPage(ctx context.Context, req *s } resp := make([]*schema.GetCommentPersonalWithPageResp, 0) for _, comment := range commentList { + if len(comment.ObjectID) == 0 { + continue + } + objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID) + if err != nil { + log.Error(err) + continue + } + if !canViewPersonalComment(objInfo, req.LoginUserID, req.IsAdminModerator) { + continue + } commentResp := &schema.GetCommentPersonalWithPageResp{ - CommentID: comment.ID, - CreatedAt: comment.CreatedAt.Unix(), - ObjectID: comment.ObjectID, - Content: comment.ParsedText, // todo trim + CommentID: comment.ID, + CreatedAt: comment.CreatedAt.Unix(), + ObjectID: comment.ObjectID, + Content: comment.ParsedText, // todo trim + ObjectType: objInfo.ObjectType, + Title: objInfo.Title, + UrlTitle: htmltext.UrlTitle(objInfo.Title), + QuestionID: objInfo.QuestionID, + AnswerID: objInfo.AnswerID, } - if len(comment.ObjectID) > 0 { - objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID) - if err != nil { - log.Error(err) - } else { - commentResp.ObjectType = objInfo.ObjectType - commentResp.Title = objInfo.Title - commentResp.UrlTitle = htmltext.UrlTitle(objInfo.Title) - commentResp.QuestionID = objInfo.QuestionID - commentResp.AnswerID = objInfo.AnswerID - if objInfo.QuestionStatus == entity.QuestionStatusDeleted { - commentResp.Title = "Deleted question" - } - } + if objInfo.QuestionStatus == entity.QuestionStatusDeleted { + commentResp.Title = "Deleted question" } resp = append(resp, commentResp) } return pager.NewPageModel(total, resp), nil } +func canViewPersonalComment(objInfo *schema.SimpleObjectInfo, userID string, isAdminModerator bool) bool { + return objInfo.CheckVisibility(userID, isAdminModerator) == nil +} + func (cs *CommentService) notificationQuestionComment(ctx context.Context, questionUserID, questionID, questionTitle, commentID, commentUserID, commentSummary string) { if questionUserID == commentUserID { diff --git a/internal/service/comment/comment_service_visibility_test.go b/internal/service/comment/comment_service_visibility_test.go new file mode 100644 index 000000000..69be2a96b --- /dev/null +++ b/internal/service/comment/comment_service_visibility_test.go @@ -0,0 +1,95 @@ +/* + * 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 comment + +import ( + "testing" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" +) + +func TestCanViewPersonalComment(t *testing.T) { + testCases := []struct { + name string + status int + show int + viewer string + isAdmin bool + expected bool + }{ + {"anonymous can view public question", entity.QuestionStatusAvailable, entity.QuestionShow, "", false, true}, + {"anonymous cannot view hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "", false, false}, + {"anonymous cannot view pending question", entity.QuestionStatusPending, entity.QuestionShow, "", false, false}, + {"anonymous cannot view deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "", false, false}, + {"question owner can view hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "question-owner", false, true}, + {"moderator can view deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "", true, true}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + objInfo := &schema.SimpleObjectInfo{ + ObjectType: constant.QuestionObjectType, + QuestionCreatorUserID: "question-owner", + QuestionStatus: testCase.status, + QuestionShow: testCase.show, + } + if got := canViewPersonalComment(objInfo, testCase.viewer, testCase.isAdmin); got != testCase.expected { + t.Fatalf("canViewPersonalComment() = %v, want %v", got, testCase.expected) + } + }) + } +} + +func TestCanViewPersonalCommentOnAnswer(t *testing.T) { + testCases := []struct { + name string + viewer string + isAdmin bool + info *schema.SimpleObjectInfo + expected bool + }{ + { + name: "anonymous cannot view answer on hidden question", + info: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusAvailable, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionHide}, + expected: false, + }, + { + name: "question owner can view answer on hidden question", + viewer: "question-owner", + info: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusAvailable, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionHide}, + expected: true, + }, + { + name: "anonymous cannot view pending answer", + info: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusPending, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionShow}, + expected: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + if got := canViewPersonalComment(testCase.info, testCase.viewer, testCase.isAdmin); got != testCase.expected { + t.Fatalf("canViewPersonalComment() = %v, want %v", got, testCase.expected) + } + }) + } +} diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index bda7b582b..e43467a5d 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -258,6 +258,14 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) ( if !exist { return "", errors.BadRequest(reason.QuestionNotFound) } + if err := (&schema.SimpleObjectInfo{ + ObjectType: constant.QuestionObjectType, + QuestionCreatorUserID: questionInfo.UserID, + QuestionStatus: questionInfo.Status, + QuestionShow: questionInfo.Show, + }).CheckVisibility(req.UserID, req.IsAdminModerator); err != nil { + return "", err + } if questionInfo.Status == entity.QuestionStatusClosed || questionInfo.Status == entity.QuestionStatusDeleted { err = errors.BadRequest(reason.AnswerCannotAddByClosedQuestion) return "", err diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index 73f66a4c1..ced0e0109 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -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) } @@ -1220,6 +1232,7 @@ func (qs *QuestionService) PersonalAnswerPage(ctx context.Context, req *schema.P cond.Page = req.Page cond.PageSize = req.PageSize cond.ShowPending = req.IsAdmin || req.LoginUserID == cond.UserID + cond.ShowHidden = req.IsAdmin || req.LoginUserID == cond.UserID if req.OrderCond == "newest" { cond.Order = entity.AnswerSearchOrderByTime } else { @@ -1372,7 +1385,7 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin } // GetQuestionsByTitle get questions by title -func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string) ( +func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title, userID string, per schema.QuestionPermission) ( resp []*schema.QuestionBaseInfo, err error) { resp = make([]*schema.QuestionBaseInfo, 0) if len(title) == 0 { @@ -1415,6 +1428,9 @@ func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string } } for _, question := range questions { + if !canViewSimilarQuestion(question, userID, per) { + continue + } item := &schema.QuestionBaseInfo{} item.ID = question.ID item.Title = question.Title @@ -1435,6 +1451,17 @@ func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string return resp, nil } +func canViewSimilarQuestion(question *entity.Question, userID string, per schema.QuestionPermission) bool { + if question == nil || question.Status == entity.QuestionStatusDeleted { + return false + } + return checkQuestionVisibility(&schema.QuestionInfoResp{ + UserID: question.UserID, + Status: question.Status, + Show: question.Show, + }, userID, per) == nil +} + // SimilarQuestion func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID string, loginUserID string) ([]*schema.QuestionPageResp, int64, error) { question, err := qs.questioncommon.Info(ctx, questionID, loginUserID) @@ -1748,7 +1775,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 } diff --git a/internal/service/content/question_service_visibility_test.go b/internal/service/content/question_service_visibility_test.go new file mode 100644 index 000000000..5cfeb5b77 --- /dev/null +++ b/internal/service/content/question_service_visibility_test.go @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package content + +import ( + "context" + "testing" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" +) + +func TestCanViewSimilarQuestionWithSQLite(t *testing.T) { + ctx := context.Background() + db, err := data.NewDB(false, &data.Database{Driver: "sqlite", Connection: ":memory:"}) + if err != nil { + t.Fatalf("create SQLite database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Sync2(new(entity.Question)); err != nil { + t.Fatalf("create question table: %v", err) + } + + questions := []*entity.Question{ + {ID: "1", UserID: "author", Title: "public match", OriginalText: "public", ParsedText: "public", Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow}, + {ID: "2", UserID: "author", Title: "pending match", OriginalText: "pending", ParsedText: "pending", Status: entity.QuestionStatusPending, Show: entity.QuestionShow}, + {ID: "3", UserID: "author", Title: "hidden match", OriginalText: "hidden", ParsedText: "hidden", Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide}, + {ID: "4", UserID: "author", Title: "deleted match", OriginalText: "deleted", ParsedText: "deleted", Status: entity.QuestionStatusDeleted, Show: entity.QuestionShow}, + {ID: "5", UserID: "viewer", Title: "own pending match", OriginalText: "own pending", ParsedText: "own pending", Status: entity.QuestionStatusPending, Show: entity.QuestionShow}, + } + for _, question := range questions { + if _, err := db.Context(ctx).Insert(question); err != nil { + t.Fatalf("insert question %s: %v", question.ID, err) + } + } + + var candidates []*entity.Question + if err := db.Context(ctx).Where("title like ?", "%match%").Find(&candidates); err != nil { + t.Fatalf("load similar-question candidates: %v", err) + } + if len(candidates) != len(questions) { + t.Fatalf("loaded %d candidates, want %d", len(candidates), len(questions)) + } + + visibleIDs := make(map[string]bool) + for _, question := range candidates { + if canViewSimilarQuestion(question, "viewer", schema.QuestionPermission{}) { + visibleIDs[question.ID] = true + } + } + if len(visibleIDs) != 2 || !visibleIDs["1"] || !visibleIDs["5"] { + t.Fatalf("visible similar questions = %v, want [1 5]", visibleIDs) + } +} + +func TestCanViewSimilarQuestion(t *testing.T) { + testCases := []struct { + name string + status int + show int + author string + viewer string + per schema.QuestionPermission + allowed bool + }{ + {"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "viewer", schema.QuestionPermission{}, true}, + {"other user's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "viewer", schema.QuestionPermission{}, false}, + {"author's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, true}, + {"reviewer's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "reviewer", schema.QuestionPermission{CanReopen: true}, true}, + {"other user's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "viewer", schema.QuestionPermission{}, false}, + {"author's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", schema.QuestionPermission{}, true}, + {"moderator's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "moderator", schema.QuestionPermission{IsAdminModerator: true}, true}, + {"deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, false}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + question := &entity.Question{UserID: testCase.author, Status: testCase.status, Show: testCase.show} + if got := canViewSimilarQuestion(question, testCase.viewer, testCase.per); got != testCase.allowed { + t.Fatalf("canViewSimilarQuestion() = %t, want %t", got, testCase.allowed) + } + }) + } +} + +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") + } + }) + } +} diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index c1f800ff9..f556fa173 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -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) } diff --git a/internal/service/content/user_service_test.go b/internal/service/content/user_service_test.go index d77a1c448..9c654aaa4 100644 --- a/internal/service/content/user_service_test.go +++ b/internal/service/content/user_service_test.go @@ -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{} diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go index 1f74769f5..045654385 100644 --- a/internal/service/content/vote_service.go +++ b/internal/service/content/vote_service.go @@ -94,6 +94,9 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s if objectInfo.IsDeleted() { return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted) } + if err := objectInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil { + return nil, err + } // make object id must be decoded objectInfo.ObjectID = req.ObjectID @@ -145,6 +148,9 @@ func (vs *VoteService) VoteDown(ctx context.Context, req *schema.VoteReq) (resp if objectInfo.IsDeleted() { return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted) } + if err := objectInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil { + return nil, err + } // make object id must be decoded objectInfo.ObjectID = req.ObjectID diff --git a/internal/service/embedding/embedding_service.go b/internal/service/embedding/embedding_service.go index c69d60d8e..62fccb427 100644 --- a/internal/service/embedding/embedding_service.go +++ b/internal/service/embedding/embedding_service.go @@ -35,6 +35,12 @@ func NewEmbeddingService() *EmbeddingService { return &EmbeddingService{} } +// Available reports whether a VectorSearch plugin is currently enabled, so +// callers can hide semantic search capabilities instead of failing at call time. +func (s *EmbeddingService) Available() bool { + return plugin.IsVectorSearchEnabled() +} + // SearchSimilar delegates to the VectorSearch plugin. // Returns an error if no plugin is enabled. func (s *EmbeddingService) SearchSimilar(ctx context.Context, query string, topK int) ([]plugin.VectorSearchResult, error) { diff --git a/internal/service/importer/importer_service.go b/internal/service/importer/importer_service.go index 9d12bf07b..1842479b3 100644 --- a/internal/service/importer/importer_service.go +++ b/internal/service/importer/importer_service.go @@ -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 = "

" + questionInfo.Content + "

" - 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 +} diff --git a/internal/service/importer/importer_service_test.go b/internal/service/importer/importer_service_test.go new file mode 100644 index 000000000..ff32740b4 --- /dev/null +++ b/internal/service/importer/importer_service_test.go @@ -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: ``, + Tags: []string{"security"}, + }) + + for _, unsafeContent := range []string{"onerror", "