mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
683 lines
18 KiB
Go
683 lines
18 KiB
Go
package readme
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestGetBaseURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
inputURL string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "nil URL",
|
|
inputURL: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "GitHub raw URL",
|
|
inputURL: "https://raw.githubusercontent.com/user/repo/main/README.md",
|
|
expected: "https://github.com/user/repo/blob/main/",
|
|
},
|
|
{
|
|
name: "GitHub raw URL with subdirectory",
|
|
inputURL: "https://raw.githubusercontent.com/user/repo/main/docs/README.md",
|
|
expected: "https://github.com/user/repo/blob/main/",
|
|
},
|
|
{
|
|
name: "GitHub raw URL with branch",
|
|
inputURL: "https://raw.githubusercontent.com/user/repo/develop/README.md",
|
|
expected: "https://github.com/user/repo/blob/develop/",
|
|
},
|
|
{
|
|
name: "regular URL",
|
|
inputURL: "https://example.com/docs/README.md",
|
|
expected: "https://example.com/docs/",
|
|
},
|
|
{
|
|
name: "URL with multiple path segments",
|
|
inputURL: "https://example.com/path/to/docs/README.md",
|
|
expected: "https://example.com/path/to/docs/",
|
|
},
|
|
{
|
|
name: "URL with root file",
|
|
inputURL: "https://example.com/README.md",
|
|
expected: "https://example.com/",
|
|
},
|
|
{
|
|
name: "URL without file",
|
|
inputURL: "https://example.com/docs/",
|
|
expected: "https://example.com/docs/",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var u *url.URL
|
|
if tt.inputURL != "" {
|
|
var err error
|
|
u, err = url.Parse(tt.inputURL)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse URL %q: %v", tt.inputURL, err)
|
|
}
|
|
}
|
|
|
|
result := getBaseURL(u)
|
|
if result != tt.expected {
|
|
t.Errorf("getBaseURL(%q) = %q, want %q", tt.inputURL, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRewriteRelativeURLs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
html string
|
|
baseURL string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "empty baseURL",
|
|
html: `<img src="./image.png">`,
|
|
baseURL: "",
|
|
expected: `<img src="./image.png">`,
|
|
},
|
|
{
|
|
name: "invalid baseURL",
|
|
html: `<img src="./image.png">`,
|
|
baseURL: "://invalid",
|
|
expected: `<img src="./image.png">`,
|
|
},
|
|
{
|
|
name: "current directory relative src",
|
|
html: `<img src="./image.png">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://example.com/docs/image.png">`,
|
|
},
|
|
{
|
|
name: "current directory relative href",
|
|
html: `<a href="./page.html">link</a>`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<a href="https://example.com/docs/page.html">link</a>`,
|
|
},
|
|
{
|
|
name: "parent directory relative src",
|
|
html: `<img src="../image.png">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://example.com/docs/../image.png">`,
|
|
},
|
|
{
|
|
name: "parent directory relative href",
|
|
html: `<a href="../page.html">link</a>`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<a href="https://example.com/docs/../page.html">link</a>`,
|
|
},
|
|
{
|
|
name: "root-relative src",
|
|
html: `<img src="/images/logo.png">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://example.com/images/logo.png">`,
|
|
},
|
|
{
|
|
name: "root-relative href",
|
|
html: `<a href="/about">link</a>`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<a href="https://example.com/about">link</a>`,
|
|
},
|
|
{
|
|
name: "mixed relative URLs",
|
|
html: `<img src="./img.png"><a href="../page.html">link</a>`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://example.com/docs/img.png"><a href="https://example.com/docs/../page.html">link</a>`,
|
|
},
|
|
{
|
|
name: "absolute URLs unchanged",
|
|
html: `<img src="https://cdn.example.com/image.png">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://cdn.example.com/image.png">`,
|
|
},
|
|
{
|
|
name: "protocol-relative URLs (incorrectly converted)",
|
|
html: `<img src="//cdn.example.com/image.png">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://example.com//cdn.example.com/image.png">`,
|
|
},
|
|
{
|
|
name: "bare relative src (no ./ prefix)",
|
|
html: `<img src="image.png">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://example.com/docs/image.png">`,
|
|
},
|
|
{
|
|
name: "bare relative href (no ./ prefix)",
|
|
html: `<a href="page.html">link</a>`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<a href="https://example.com/docs/page.html">link</a>`,
|
|
},
|
|
{
|
|
name: "bare relative with path",
|
|
html: `<img src="images/logo.png">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="https://example.com/docs/images/logo.png">`,
|
|
},
|
|
{
|
|
name: "anchor links unchanged",
|
|
html: `<a href="#section">link</a>`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<a href="#section">link</a>`,
|
|
},
|
|
{
|
|
name: "data URLs unchanged",
|
|
html: `<img src="data:image/png;base64,abc123">`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<img src="data:image/png;base64,abc123">`,
|
|
},
|
|
{
|
|
name: "mailto links unchanged",
|
|
html: `<a href="mailto:test@example.com">email</a>`,
|
|
baseURL: "https://example.com/docs/",
|
|
expected: `<a href="mailto:test@example.com">email</a>`,
|
|
},
|
|
{
|
|
name: "mixed bare and prefixed relative URLs",
|
|
html: `<img src="slices_and_lucy.png"><a href="./other.md">link</a>`,
|
|
baseURL: "https://github.com/user/repo/blob/main/",
|
|
expected: `<img src="https://github.com/user/repo/blob/main/slices_and_lucy.png"><a href="https://github.com/user/repo/blob/main/other.md">link</a>`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := rewriteRelativeURLs(tt.html, tt.baseURL)
|
|
if result != tt.expected {
|
|
t.Errorf("rewriteRelativeURLs() = %q, want %q", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetcher_RenderMarkdown(t *testing.T) {
|
|
fetcher := NewFetcher()
|
|
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
wantContain string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple paragraph",
|
|
content: "Hello, world!",
|
|
wantContain: "<p>Hello, world!</p>",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "heading",
|
|
content: "# My App",
|
|
wantContain: "<h1",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "bold text",
|
|
content: "This is **bold** text.",
|
|
wantContain: "<strong>bold</strong>",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "italic text",
|
|
content: "This is *italic* text.",
|
|
wantContain: "<em>italic</em>",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "code block",
|
|
content: "```\ncode here\n```",
|
|
wantContain: "<pre>",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "link",
|
|
content: "[Link text](https://example.com)",
|
|
wantContain: `href="https://example.com"`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "image",
|
|
content: "",
|
|
wantContain: `src="https://example.com/image.png"`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "unordered list",
|
|
content: "- Item 1\n- Item 2",
|
|
wantContain: "<ul>",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "ordered list",
|
|
content: "1. Item 1\n2. Item 2",
|
|
wantContain: "<ol>",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty content",
|
|
content: "",
|
|
wantContain: "",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "complex markdown",
|
|
content: "# Title\n\nA paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n```go\nfunc main() {}\n```",
|
|
wantContain: "<h1",
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
html, err := fetcher.RenderMarkdown([]byte(tt.content))
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("RenderMarkdown() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !tt.wantErr && tt.wantContain != "" {
|
|
if !containsSubstring(html, tt.wantContain) {
|
|
t.Errorf("RenderMarkdown() = %q, want to contain %q", html, tt.wantContain)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRenderMarkdown_XSSRegression verifies that XSS payloads cannot survive
|
|
// the goldmark→bluemonday pipeline. Goldmark (without WithUnsafe) replaces raw
|
|
// HTML with "<!-- raw HTML omitted -->"; bluemonday then strips that comment and
|
|
// any event-handler attributes or dangerous protocols.
|
|
func TestRenderMarkdown_XSSRegression(t *testing.T) {
|
|
fetcher := NewFetcher()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantAbsent []string // must NOT appear in output
|
|
wantPresent []string // MUST appear in output (safe rendered form)
|
|
}{
|
|
{
|
|
name: "inline script tag",
|
|
input: "<script>alert('xss')</script>",
|
|
wantAbsent: []string{"<script>", "alert(", "</script>"},
|
|
},
|
|
{
|
|
name: "script tag in fenced code block is escaped, not executed",
|
|
input: "```\n<script>alert('xss')</script>\n```",
|
|
// goldmark HTML-escapes content inside code blocks
|
|
wantAbsent: []string{"<script>alert("},
|
|
wantPresent: []string{"<script>"},
|
|
},
|
|
{
|
|
name: "javascript: protocol in markdown link",
|
|
input: "[click me](javascript:alert('xss'))",
|
|
wantAbsent: []string{"javascript:"},
|
|
},
|
|
{
|
|
name: "javascript: protocol in inline HTML anchor",
|
|
input: `<a href="javascript:alert('xss')">click</a>`,
|
|
wantAbsent: []string{"javascript:"},
|
|
},
|
|
{
|
|
name: "img onerror via inline HTML",
|
|
input: `<img src="x" onerror="alert('xss')">`,
|
|
wantAbsent: []string{"onerror", "alert("},
|
|
},
|
|
{
|
|
name: "img with injected attribute via markdown image syntax",
|
|
input: `)`,
|
|
// Malformed URL — goldmark rejects the image and renders it as literal escaped text.
|
|
// The actual XSS vector (an <img> with an onerror attribute) cannot form.
|
|
wantAbsent: []string{"<img"},
|
|
},
|
|
{
|
|
name: "svg onload",
|
|
input: `<svg onload="alert('xss')"><circle r="10"/></svg>`,
|
|
wantAbsent: []string{"onload", "alert("},
|
|
},
|
|
{
|
|
name: "iframe element",
|
|
input: `<iframe src="https://evil.com"></iframe>`,
|
|
wantAbsent: []string{"<iframe"},
|
|
},
|
|
{
|
|
name: "style tag",
|
|
input: `<style>body { background: red; }</style>`,
|
|
wantAbsent: []string{"<style>"},
|
|
},
|
|
{
|
|
name: "meta refresh redirect",
|
|
input: `<meta http-equiv="refresh" content="0;url=https://evil.com">`,
|
|
wantAbsent: []string{"<meta"},
|
|
},
|
|
{
|
|
name: "data URI in img src",
|
|
input: `<img src="data:text/html,<script>alert(1)</script>">`,
|
|
wantAbsent: []string{"data:text/html", "alert("},
|
|
},
|
|
{
|
|
name: "form action exfiltration",
|
|
input: `<form action="https://evil.com"><button>Submit</button></form>`,
|
|
wantAbsent: []string{"<form", "action="},
|
|
},
|
|
{
|
|
name: "onclick on arbitrary element",
|
|
input: `<p onclick="alert('xss')">click me</p>`,
|
|
wantAbsent: []string{"onclick", "alert("},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := fetcher.RenderMarkdown([]byte(tt.input))
|
|
if err != nil {
|
|
t.Fatalf("RenderMarkdown() unexpected error: %v", err)
|
|
}
|
|
for _, bad := range tt.wantAbsent {
|
|
if strings.Contains(result, bad) {
|
|
t.Errorf("output contains dangerous string %q\nfull output: %s", bad, result)
|
|
}
|
|
}
|
|
for _, good := range tt.wantPresent {
|
|
if !strings.Contains(result, good) {
|
|
t.Errorf("output missing expected string %q\nfull output: %s", good, result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func containsSubstring(s, substr string) bool {
|
|
return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr)))
|
|
}
|
|
|
|
func containsSubstringHelper(s, substr string) bool {
|
|
for i := range len(s) - len(substr) + 1 {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestLooksLikeHTML(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "empty content",
|
|
content: "",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "markdown content",
|
|
content: "# Hello World\n\nThis is a README.",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "plain text",
|
|
content: "Just some plain text without any HTML.",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "doctype html",
|
|
content: "<!DOCTYPE html>\n<html><body>Page</body></html>",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "doctype html lowercase",
|
|
content: "<!doctype html>\n<html><body>Page</body></html>",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "html tag only",
|
|
content: "<html><head></head><body>Page</body></html>",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "html tag with whitespace",
|
|
content: " \n <html>\n<body>Page</body></html>",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "xml declaration",
|
|
content: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<html>...</html>",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "soft 404 page",
|
|
content: "<!DOCTYPE html><html><head><title>Page Not Found</title></head><body><h1>404</h1></body></html>",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "markdown with inline html",
|
|
content: "# Title\n\nSome text with <strong>bold</strong> inline.",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "markdown starting with hash",
|
|
content: "## Section\n\nContent here.",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := LooksLikeHTML([]byte(tt.content))
|
|
if result != tt.expected {
|
|
t.Errorf("looksLikeHTML(%q) = %v, want %v", tt.content, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetcher_FetchRaw(t *testing.T) {
|
|
fetcher := NewFetcher()
|
|
|
|
tests := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
wantErr bool
|
|
errContains string
|
|
wantContent string
|
|
}{
|
|
{
|
|
name: "successful markdown fetch",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte("# Hello World\n\nThis is markdown."))
|
|
},
|
|
wantErr: false,
|
|
wantContent: "# Hello World",
|
|
},
|
|
{
|
|
name: "rejects HTML content type",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte("<html><body>Error</body></html>"))
|
|
},
|
|
wantErr: true,
|
|
errContains: "unsupported content type",
|
|
},
|
|
{
|
|
name: "rejects soft 404 HTML content",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte("<!DOCTYPE html><html><body>404 Not Found</body></html>"))
|
|
},
|
|
wantErr: true,
|
|
errContains: "detected HTML content",
|
|
},
|
|
{
|
|
name: "rejects 404 status",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte("Not Found"))
|
|
},
|
|
wantErr: true,
|
|
errContains: "unexpected status code: 404",
|
|
},
|
|
{
|
|
name: "rejects 500 status",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("Internal Server Error"))
|
|
},
|
|
wantErr: true,
|
|
errContains: "unexpected status code: 500",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
server := httptest.NewServer(tt.handler)
|
|
defer server.Close()
|
|
|
|
content, err := fetcher.FetchRaw(context.Background(), server.URL)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("FetchRaw() expected error containing %q, got nil", tt.errContains)
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.errContains) {
|
|
t.Errorf("FetchRaw() error = %q, want error containing %q", err.Error(), tt.errContains)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("FetchRaw() unexpected error: %v", err)
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(string(content), tt.wantContent) {
|
|
t.Errorf("FetchRaw() content = %q, want content containing %q", string(content), tt.wantContent)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetcher_FetchRaw_URLValidation(t *testing.T) {
|
|
fetcher := NewFetcher()
|
|
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "empty URL",
|
|
url: "",
|
|
errContains: "empty README URL",
|
|
},
|
|
{
|
|
name: "invalid URL scheme",
|
|
url: "ftp://example.com/README.md",
|
|
errContains: "invalid URL scheme",
|
|
},
|
|
{
|
|
name: "file URL scheme",
|
|
url: "file:///etc/passwd",
|
|
errContains: "invalid URL scheme",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := fetcher.FetchRaw(context.Background(), tt.url)
|
|
if err == nil {
|
|
t.Errorf("FetchRaw(%q) expected error, got nil", tt.url)
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.errContains) {
|
|
t.Errorf("FetchRaw(%q) error = %q, want error containing %q", tt.url, err.Error(), tt.errContains)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetcher_FetchAndRender(t *testing.T) {
|
|
fetcher := NewFetcher()
|
|
|
|
tests := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
wantErr bool
|
|
errContains string
|
|
wantContain string
|
|
}{
|
|
{
|
|
name: "renders markdown to HTML",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte("# Hello World\n\nThis is **bold** text."))
|
|
},
|
|
wantErr: false,
|
|
wantContain: "<strong>bold</strong>",
|
|
},
|
|
{
|
|
name: "rejects HTML content type",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte("<html><body>Error</body></html>"))
|
|
},
|
|
wantErr: true,
|
|
errContains: "unsupported content type",
|
|
},
|
|
{
|
|
name: "rejects soft 404",
|
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte("<!doctype html><html><body>Not Found</body></html>"))
|
|
},
|
|
wantErr: true,
|
|
errContains: "detected HTML content",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
server := httptest.NewServer(tt.handler)
|
|
defer server.Close()
|
|
|
|
html, err := fetcher.FetchAndRender(context.Background(), server.URL)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("FetchAndRender() expected error containing %q, got nil", tt.errContains)
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.errContains) {
|
|
t.Errorf("FetchAndRender() error = %q, want error containing %q", err.Error(), tt.errContains)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("FetchAndRender() unexpected error: %v", err)
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(html, tt.wantContain) {
|
|
t.Errorf("FetchAndRender() = %q, want HTML containing %q", html, tt.wantContain)
|
|
}
|
|
})
|
|
}
|
|
}
|