mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
KaTeX 0.16.11 embarqué et servi sous /katex/ (Go et façade)
Repris d'AJEAN (d6136caa, #123) : script, feuille de style, polices woff2 et licence MIT sous internal/loki/ui/katex/, embarqués par le go:embed et par build.rs de la façade. Pas de CDN : l'interface doit marcher hors ligne. Route /katex/ des deux côtés avec les mêmes en-têtes : katex.min.js (application/javascript), katex.min.css (text/css), fonts/*.woff2 (font/woff2), Cache-Control public 1 jour ; script et feuille compressés en gzip, polices non. Contrairement à AJEAN (http.FileServer), le Go (web_katex.go) ne sert que ces fichiers, avec un Content-Type fixe : ni index de dossier, ni type décidé par le registre MIME de l'OS. La façade (ui.rs) rend les mêmes réponses ; le reste de /katex/ part au Go (404). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a45f19831
commit
99c96a6c33
28 files changed
+267
-1
No files matched your search
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&",">":">","<":"<",'"':""","'":"'"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
|
||||
@@ -0,0 +1,59 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// web_katex.go — KaTeX 0.16.11 (formules LaTeX des réponses), repris d'AJEAN.
|
||||
//
|
||||
// Embarqué sous ui/katex/ et servi sous /katex/ : l'UI ne le charge qu'à la
|
||||
// première formule d'une réponse (00-md.js), et il doit marcher hors ligne,
|
||||
// sans appeler un CDN. Seuls le script, la feuille de style et les polices
|
||||
// woff2 sont servis — pas d'index de dossier — avec un Content-Type fixe : le
|
||||
// registre MIME de l'OS (Windows notamment) ne doit pas le décider, et la
|
||||
// façade Rust (rust/loki-rs/src/ui.rs) rend exactement les mêmes en-têtes.
|
||||
|
||||
// katexTypes : extensions servies et leur Content-Type.
|
||||
var katexTypes = map[string]string{
|
||||
".js": "application/javascript",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
|
||||
// katexFile : chemin relatif à ui/katex/ d'une ressource servable, ou "" si la
|
||||
// demande n'en est pas une (racine, dossier, autre extension, remontée).
|
||||
func katexFile(urlPath string) (rel, ctype string) {
|
||||
rel = strings.TrimPrefix(urlPath, "/katex/")
|
||||
if rel == urlPath || rel == "" || strings.Contains(rel, "..") || path.Clean(rel) != rel {
|
||||
return "", ""
|
||||
}
|
||||
ctype, ok := katexTypes[path.Ext(rel)]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return rel, ctype
|
||||
}
|
||||
|
||||
func handleKatex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rel, ctype := katexFile(r.URL.Path)
|
||||
if rel == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, err := uiFS.ReadFile("ui/katex/" + rel)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
||||
w.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func getKatex(t *testing.T, méthode, chemin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handleKatex(rec, httptest.NewRequest(méthode, chemin, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
// Le script, embarqué, est servi sous /katex/ avec les en-têtes que rend aussi
|
||||
// la façade Rust (ui.rs).
|
||||
func TestKatexScriptServi(t *testing.T) {
|
||||
rec := getKatex(t, http.MethodGet, "/katex/katex.min.js")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code %d", rec.Code)
|
||||
}
|
||||
h := rec.Header()
|
||||
if got := h.Get("Content-Type"); got != "application/javascript" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := h.Get("Cache-Control"); got != "public, max-age=86400" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `version:"0.16.11"`) {
|
||||
t.Error("katex.min.js n'est pas KaTeX 0.16.11")
|
||||
}
|
||||
if h.Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
|
||||
t.Errorf("Content-Length = %q pour %d octets", h.Get("Content-Length"), rec.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// Chaque police woff2 que la feuille de style demande est embarquée et servie.
|
||||
func TestKatexFeuilleEtPolices(t *testing.T) {
|
||||
rec := getKatex(t, http.MethodGet, "/katex/katex.min.css")
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" {
|
||||
t.Fatalf("css : code %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
polices := regexp.MustCompile(`url\((fonts/[^)]+\.woff2)\)`).FindAllStringSubmatch(rec.Body.String(), -1)
|
||||
if len(polices) < 10 {
|
||||
t.Fatalf("%d polices woff2 dans la feuille", len(polices))
|
||||
}
|
||||
for _, m := range polices {
|
||||
r := getKatex(t, http.MethodGet, "/katex/"+m[1])
|
||||
if r.Code != http.StatusOK || r.Header().Get("Content-Type") != "font/woff2" || r.Body.Len() == 0 {
|
||||
t.Errorf("%s : code %d, type %q", m[1], r.Code, r.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rien d'autre : ni index de dossier, ni licence, ni remontée, ni POST.
|
||||
func TestKatexRefus(t *testing.T) {
|
||||
for _, c := range []struct{ méthode, chemin string }{
|
||||
{http.MethodGet, "/katex/"},
|
||||
{http.MethodGet, "/katex/fonts/"},
|
||||
{http.MethodGet, "/katex/LICENSE"},
|
||||
{http.MethodGet, "/katex/fonts/absente.woff2"},
|
||||
{http.MethodGet, "/katex/../index.html"},
|
||||
{http.MethodGet, "/katex/fonts/../katex.min.js"},
|
||||
{http.MethodGet, "/katexkatex.min.js"},
|
||||
{http.MethodPost, "/katex/katex.min.js"},
|
||||
} {
|
||||
if rec := getKatex(t, c.méthode, c.chemin); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s : code %d, attendu 404", c.méthode, c.chemin, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derrière gzipHandler (comme en production) : script et feuille compressés,
|
||||
// polices non — comme la façade.
|
||||
func TestKatexCompression(t *testing.T) {
|
||||
h := gzipHandler(http.HandlerFunc(handleKatex))
|
||||
for chemin, gz := range map[string]bool{
|
||||
"/katex/katex.min.js": true,
|
||||
"/katex/katex.min.css": true,
|
||||
"/katex/fonts/KaTeX_Main-Regular.woff2": false,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, chemin, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("Content-Encoding") == "gzip"; got != gz {
|
||||
t.Errorf("%s : gzip %v, attendu %v", chemin, got, gz)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
//go:generate go run ../../tools/assemble-ui ui
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
|
||||
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2 ui/katex
|
||||
var uiFS embed.FS
|
||||
|
||||
// cmdWeb starts the HTTP server on the given port (default 8090).
|
||||
@@ -217,6 +217,9 @@ func newWebMux() *http.ServeMux {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Write(b)
|
||||
})
|
||||
// KaTeX (formules LaTeX des réponses, web_katex.go) : chargé par l'UI
|
||||
// seulement quand une réponse contient des maths. Embarqué, comme les polices.
|
||||
mux.HandleFunc("/katex/", handleKatex)
|
||||
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
|
||||
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
|
||||
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
|
||||
|
||||
Reference in new issue
Block a user