606 lines
18 KiB
Go
606 lines
18 KiB
Go
// Package ingest accepts information into Relata and drives it through the
|
|
// processing pipeline.
|
|
//
|
|
// The governing rule is that the original is preserved. Whatever a user
|
|
// supplies is stored verbatim — bytes for uploads, text for pastes — before any
|
|
// parsing is attempted. If parsing later fails, the failure is recorded as a
|
|
// warning on the item and the original remains retrievable and reprocessable.
|
|
package ingest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/andydixon/relata/internal/audit"
|
|
"github.com/andydixon/relata/internal/config"
|
|
"github.com/andydixon/relata/internal/domain"
|
|
"github.com/andydixon/relata/internal/ids"
|
|
"github.com/andydixon/relata/internal/observability"
|
|
"github.com/andydixon/relata/internal/parsing"
|
|
)
|
|
|
|
// Request is a single item to ingest.
|
|
type Request struct {
|
|
Title string
|
|
Content []byte
|
|
Filename string
|
|
// DeclaredMIME is what the client claimed. It is a hint only; the real type
|
|
// is determined from the content.
|
|
DeclaredMIME string
|
|
SourceType domain.SourceType
|
|
Metadata map[string]string
|
|
// CreatedAt is the user-asserted creation time of the information itself.
|
|
CreatedAt *time.Time
|
|
// AllowDuplicate must be set explicitly to store content that already
|
|
// exists byte-for-byte.
|
|
AllowDuplicate bool
|
|
}
|
|
|
|
// Result describes what ingestion produced.
|
|
type Result struct {
|
|
Item *domain.Item
|
|
// Duplicate is the existing item when identical content was found.
|
|
Duplicate *domain.Item
|
|
// Stored is false when an exact duplicate was refused.
|
|
Stored bool
|
|
JobID string
|
|
}
|
|
|
|
// Service ingests items.
|
|
type Service struct {
|
|
items domain.ItemRepository
|
|
jobs domain.JobRepository
|
|
cfg *config.Config
|
|
audit *audit.Recorder
|
|
metrics *observability.Metrics
|
|
// admit bounds concurrent ingestion so a burst of uploads cannot exhaust
|
|
// memory: each in-flight request holds its content in memory.
|
|
admit chan struct{}
|
|
}
|
|
|
|
// NewService builds the ingestion service.
|
|
func NewService(items domain.ItemRepository, jobRepo domain.JobRepository, cfg *config.Config,
|
|
rec *audit.Recorder, m *observability.Metrics) *Service {
|
|
n := cfg.Uploads.MaxConcurrentIngest
|
|
if n <= 0 {
|
|
n = 4
|
|
}
|
|
return &Service{
|
|
items: items,
|
|
jobs: jobRepo,
|
|
cfg: cfg,
|
|
audit: rec,
|
|
metrics: m,
|
|
admit: make(chan struct{}, n),
|
|
}
|
|
}
|
|
|
|
// ValidationError describes why an ingestion request was rejected.
|
|
type ValidationError struct {
|
|
Field string
|
|
Message string
|
|
}
|
|
|
|
func (e *ValidationError) Error() string { return e.Field + ": " + e.Message }
|
|
func (e *ValidationError) Unwrap() error { return domain.ErrInvalidInput }
|
|
|
|
// DuplicateError reports that identical content already exists.
|
|
type DuplicateError struct {
|
|
ExistingID string
|
|
ExistingTitle string
|
|
}
|
|
|
|
func (e *DuplicateError) Error() string {
|
|
if e.ExistingID == "" {
|
|
// A race we lost, where the winning item was no longer readable.
|
|
return "identical content already exists"
|
|
}
|
|
return fmt.Sprintf("identical content already exists as item %s (%q)", e.ExistingID, e.ExistingTitle)
|
|
}
|
|
func (e *DuplicateError) Unwrap() error { return domain.ErrDuplicate }
|
|
|
|
// Ingest validates, stores and queues an item for processing.
|
|
func (s *Service) Ingest(ctx context.Context, p *domain.Principal, req Request) (*Result, error) {
|
|
select {
|
|
case s.admit <- struct{}{}:
|
|
defer func() { <-s.admit }()
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
if p == nil || p.WorkspaceID == "" {
|
|
return nil, domain.ErrWorkspaceMiss
|
|
}
|
|
if !p.Role.CanWrite() {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
|
|
item, err := s.build(ctx, p, req)
|
|
if err != nil {
|
|
s.countIngest(string(req.SourceType), "rejected")
|
|
return nil, err
|
|
}
|
|
|
|
// Exact duplicate detection, before anything is written.
|
|
existing, err := s.items.FindByContentHash(ctx, p.WorkspaceID, item.ContentHash)
|
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
if !req.AllowDuplicate {
|
|
s.countIngest(string(req.SourceType), "duplicate")
|
|
return &Result{Duplicate: existing, Stored: false},
|
|
&DuplicateError{ExistingID: existing.ID, ExistingTitle: existing.Title}
|
|
}
|
|
// The user asked for it explicitly; record the link rather than hide it.
|
|
item.DuplicateOf = existing.ID
|
|
}
|
|
|
|
// Persist the original bytes for binary formats before the item row, so a
|
|
// row never claims a file that does not exist.
|
|
if item.MIMEType == "application/pdf" || isDOCX(item.MIMEType) {
|
|
path, err := s.storeOriginal(p.WorkspaceID, item.ID, req.Filename, req.Content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store original file: %w", err)
|
|
}
|
|
item.OriginalStoredPath = path
|
|
}
|
|
|
|
// discard removes the stored original when the item will not be created.
|
|
discard := func() {
|
|
if item.OriginalStoredPath != "" {
|
|
_ = os.Remove(item.OriginalStoredPath)
|
|
item.OriginalStoredPath = ""
|
|
}
|
|
}
|
|
|
|
if err := s.items.Create(ctx, item); err != nil {
|
|
// A concurrent request stored identical content between the check above
|
|
// and this insert: the unique index on content_hash caught what the
|
|
// check could not.
|
|
if !errors.Is(err, domain.ErrDuplicate) {
|
|
discard()
|
|
return nil, err
|
|
}
|
|
|
|
winner, lookupErr := s.items.FindByContentHash(ctx, p.WorkspaceID, item.ContentHash)
|
|
if lookupErr != nil || winner == nil {
|
|
// The winner is not readable — deleted in the interim, or the
|
|
// lookup failed. Still a duplicate, just without a name to give.
|
|
discard()
|
|
s.countIngest(string(req.SourceType), "duplicate")
|
|
return &Result{Stored: false}, &DuplicateError{}
|
|
}
|
|
|
|
if req.AllowDuplicate {
|
|
// The caller asked for a second copy to be kept. Losing the race
|
|
// does not change that answer — it only reveals which item this one
|
|
// is a copy of. Recording that link puts the item outside the
|
|
// uniqueness constraint, so the retry succeeds.
|
|
item.DuplicateOf = winner.ID
|
|
if err := s.items.Create(ctx, item); err != nil {
|
|
discard()
|
|
return nil, err
|
|
}
|
|
} else {
|
|
discard()
|
|
s.countIngest(string(req.SourceType), "duplicate")
|
|
return &Result{Duplicate: winner, Stored: false},
|
|
&DuplicateError{ExistingID: winner.ID, ExistingTitle: winner.Title}
|
|
}
|
|
}
|
|
|
|
job := &domain.Job{
|
|
ID: ids.New(),
|
|
WorkspaceID: p.WorkspaceID,
|
|
Type: domain.JobParseItem,
|
|
Status: domain.JobPending,
|
|
ItemID: item.ID,
|
|
Priority: 10,
|
|
MaxAttempts: s.cfg.Workers.MaxAttempts,
|
|
IdempotencyKey: ids.Deterministic(p.WorkspaceID, item.ID, string(domain.JobParseItem), "initial"),
|
|
}
|
|
if err := s.jobs.Enqueue(ctx, job); err != nil {
|
|
// The item exists and is recoverable; surface the queueing failure
|
|
// rather than pretending processing has started.
|
|
return &Result{Item: item, Stored: true}, fmt.Errorf("queue item for processing: %w", err)
|
|
}
|
|
|
|
s.audit.Record(ctx, p, domain.AuditItemCreated, "item", item.ID, "success", map[string]string{
|
|
"source_type": string(item.SourceType),
|
|
"mime_type": item.MIMEType,
|
|
"size_bytes": fmt.Sprint(item.SizeBytes),
|
|
"duplicate": boolStr(item.DuplicateOf != ""),
|
|
})
|
|
s.countIngest(string(item.SourceType), "accepted")
|
|
|
|
return &Result{Item: item, Stored: true, JobID: job.ID, Duplicate: existing}, nil
|
|
}
|
|
|
|
func (s *Service) countIngest(sourceType, outcome string) {
|
|
if s.metrics != nil {
|
|
if sourceType == "" {
|
|
sourceType = "unknown"
|
|
}
|
|
s.metrics.IngestItems.WithLabelValues(sourceType, outcome).Inc()
|
|
}
|
|
}
|
|
|
|
// build validates a request and constructs the item record.
|
|
func (s *Service) build(ctx context.Context, p *domain.Principal, req Request) (*domain.Item, error) {
|
|
lim := s.cfg.Uploads
|
|
|
|
if len(req.Content) == 0 {
|
|
return nil, &ValidationError{Field: "content", Message: "no content was supplied"}
|
|
}
|
|
if int64(len(req.Content)) > lim.MaxUploadBytes {
|
|
return nil, &ValidationError{
|
|
Field: "content",
|
|
Message: fmt.Sprintf("content is %d bytes, larger than the %d byte limit", len(req.Content), lim.MaxUploadBytes),
|
|
}
|
|
}
|
|
|
|
// Determine the real type from the bytes. A client's declared type and the
|
|
// filename are hints; neither is trusted.
|
|
sniff, err := parsing.Sniff(req.Content, req.DeclaredMIME, req.Filename)
|
|
switch {
|
|
case errors.Is(err, parsing.ErrArchiveRejected):
|
|
return nil, &ValidationError{
|
|
Field: "content",
|
|
Message: "archive files are not accepted. Please upload the documents individually.",
|
|
}
|
|
case errors.Is(err, parsing.ErrExecutableRejected):
|
|
return nil, &ValidationError{
|
|
Field: "content",
|
|
Message: "executable content is not accepted.",
|
|
}
|
|
case err != nil:
|
|
return nil, &ValidationError{
|
|
Field: "content",
|
|
Message: fmt.Sprintf("this file type is not supported. Relata accepts plain text, Markdown, JSON, PDF and DOCX (detected: %s)",
|
|
sniff.MIME),
|
|
}
|
|
}
|
|
if !parsing.Supported(sniff.MIME) {
|
|
return nil, &ValidationError{
|
|
Field: "content",
|
|
Message: "this file type is not supported. Relata accepts plain text, Markdown, JSON, PDF and DOCX.",
|
|
}
|
|
}
|
|
|
|
title, err := s.cleanTitle(req.Title, req.Filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta, err := s.cleanMetadata(req.Metadata)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sourceType := req.SourceType
|
|
if sourceType == "" {
|
|
sourceType = inferSourceType(sniff.MIME)
|
|
}
|
|
if !sourceType.Valid() {
|
|
return nil, &ValidationError{Field: "source_type", Message: "unrecognised source type"}
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
created := now
|
|
if req.CreatedAt != nil {
|
|
created = req.CreatedAt.UTC()
|
|
// A creation date far in the future is almost certainly a mistake and
|
|
// would distort every temporal signal computed from it.
|
|
if created.After(now.Add(24 * time.Hour)) {
|
|
return nil, &ValidationError{Field: "created_at", Message: "the creation date cannot be in the future"}
|
|
}
|
|
}
|
|
|
|
sum := sha256.Sum256(req.Content)
|
|
item := &domain.Item{
|
|
ID: ids.New(),
|
|
WorkspaceID: p.WorkspaceID,
|
|
Title: title,
|
|
SourceType: sourceType,
|
|
MIMEType: sniff.MIME,
|
|
SizeBytes: int64(len(req.Content)),
|
|
ContentHash: hex.EncodeToString(sum[:]),
|
|
Metadata: meta,
|
|
CreatedAt: created,
|
|
IngestedAt: now,
|
|
UpdatedAt: now,
|
|
Status: domain.StatusPending,
|
|
CreatedBy: p.UserID,
|
|
}
|
|
|
|
if fn := SanitiseFilename(req.Filename); fn != "" {
|
|
item.OriginalFilename = fn
|
|
}
|
|
|
|
// Text-shaped content is kept inline; binary content is kept on disk.
|
|
if isTextual(sniff.MIME) {
|
|
if !utf8.Valid(req.Content) {
|
|
return nil, &ValidationError{Field: "content", Message: "text content is not valid UTF-8"}
|
|
}
|
|
item.OriginalText = string(req.Content)
|
|
}
|
|
|
|
if sniff.Declared != "" && sniff.FromContent && sniff.Declared != sniff.MIME {
|
|
// Worth recording: it may be innocent, or it may be an attempt to
|
|
// smuggle one format past a check for another.
|
|
item.Warnings = append(item.Warnings, domain.ParseWarning{
|
|
Code: "mime_mismatch",
|
|
Message: "The file's actual type differs from the type the browser reported. Relata used the type detected from the file's contents.",
|
|
Detail: fmt.Sprintf("declared=%s detected=%s", sniff.Declared, sniff.MIME),
|
|
})
|
|
}
|
|
|
|
observability.LoggerFrom(ctx).Info("item accepted for ingestion",
|
|
"item_id", item.ID, "mime_type", item.MIMEType, "size_bytes", item.SizeBytes,
|
|
"source_type", string(item.SourceType))
|
|
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Service) cleanTitle(title, filename string) (string, error) {
|
|
t := strings.TrimSpace(title)
|
|
if t == "" {
|
|
t = strings.TrimSpace(SanitiseFilename(filename))
|
|
}
|
|
if t == "" {
|
|
t = "Untitled item"
|
|
}
|
|
t = stripControl(t)
|
|
if utf8.RuneCountInString(t) > s.cfg.Uploads.MaxTitleLen {
|
|
return "", &ValidationError{
|
|
Field: "title",
|
|
Message: fmt.Sprintf("title must be at most %d characters", s.cfg.Uploads.MaxTitleLen),
|
|
}
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// metadataKeyPattern restricts metadata keys to a safe, predictable shape.
|
|
//
|
|
// This is not cosmetic: keys become part of stored documents and are displayed
|
|
// and compared. Restricting them keeps a key from resembling a MongoDB operator
|
|
// or carrying markup.
|
|
var metadataKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,63}$`)
|
|
|
|
func (s *Service) cleanMetadata(in map[string]string) (map[string]string, error) {
|
|
if len(in) == 0 {
|
|
return nil, nil
|
|
}
|
|
lim := s.cfg.Uploads
|
|
if len(in) > lim.MaxMetadataKeys {
|
|
return nil, &ValidationError{
|
|
Field: "metadata",
|
|
Message: fmt.Sprintf("at most %d metadata entries are allowed", lim.MaxMetadataKeys),
|
|
}
|
|
}
|
|
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
key := strings.TrimSpace(k)
|
|
if !metadataKeyPattern.MatchString(key) {
|
|
return nil, &ValidationError{
|
|
Field: "metadata",
|
|
Message: fmt.Sprintf("metadata key %q is invalid; use letters, digits, dot, dash or underscore", truncate(key, 40)),
|
|
}
|
|
}
|
|
// A key beginning with $ or containing a dot has special meaning to
|
|
// MongoDB. The pattern above already excludes a leading $; dots are
|
|
// permitted in the key text but the value is always stored as a plain
|
|
// string inside a map, never interpreted as a path.
|
|
val := stripControl(v)
|
|
if len(val) > lim.MaxMetadataValueLen {
|
|
return nil, &ValidationError{
|
|
Field: "metadata",
|
|
Message: fmt.Sprintf("metadata value for %q exceeds %d characters", key, lim.MaxMetadataValueLen),
|
|
}
|
|
}
|
|
out[key] = val
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// storeOriginal writes uploaded bytes to disk.
|
|
//
|
|
// The stored name is derived from the item's own UUID, never from the
|
|
// user-supplied filename, so a crafted name cannot influence the path at all.
|
|
// Files are written without any execute permission and the directory layout is
|
|
// per-workspace.
|
|
func (s *Service) storeOriginal(workspaceID, itemID, filename string, content []byte) (string, error) {
|
|
if !ids.Valid(itemID) || !ids.Valid(workspaceID) {
|
|
return "", fmt.Errorf("refusing to store a file under a non-UUID identifier")
|
|
}
|
|
|
|
dir := filepath.Join(s.cfg.Uploads.Dir, workspaceID)
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// The extension is taken from a fixed allow-list keyed on the *detected*
|
|
// type, not from the filename, so ".php" or ".sh" can never appear.
|
|
ext := ""
|
|
switch {
|
|
case strings.HasSuffix(strings.ToLower(SanitiseFilename(filename)), ".pdf"):
|
|
ext = ".pdf"
|
|
case strings.HasSuffix(strings.ToLower(SanitiseFilename(filename)), ".docx"):
|
|
ext = ".docx"
|
|
}
|
|
|
|
path := filepath.Join(dir, itemID+ext)
|
|
|
|
// Belt and braces: confirm the final path is inside the upload directory.
|
|
absDir, err := filepath.Abs(s.cfg.Uploads.Dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.HasPrefix(absPath, absDir+string(os.PathSeparator)) {
|
|
return "", fmt.Errorf("refusing to write outside the upload directory")
|
|
}
|
|
|
|
// 0600: readable and writable by the service account only, never executable.
|
|
if err := os.WriteFile(absPath, content, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
return absPath, nil
|
|
}
|
|
|
|
// ReadOriginal returns the stored bytes of an uploaded file.
|
|
func (s *Service) ReadOriginal(item *domain.Item) ([]byte, error) {
|
|
if item.OriginalStoredPath == "" {
|
|
if item.OriginalText != "" {
|
|
return []byte(item.OriginalText), nil
|
|
}
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
absDir, err := filepath.Abs(s.cfg.Uploads.Dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Re-check containment on read: the stored path came from the database,
|
|
// which is trusted, but a cheap check costs nothing and bounds the damage
|
|
// from a corrupted record.
|
|
if !strings.HasPrefix(item.OriginalStoredPath, absDir+string(os.PathSeparator)) {
|
|
return nil, fmt.Errorf("stored file path is outside the upload directory")
|
|
}
|
|
return os.ReadFile(item.OriginalStoredPath)
|
|
}
|
|
|
|
// DeleteOriginal removes an item's stored file.
|
|
func (s *Service) DeleteOriginal(item *domain.Item) error {
|
|
if item.OriginalStoredPath == "" {
|
|
return nil
|
|
}
|
|
absDir, err := filepath.Abs(s.cfg.Uploads.Dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.HasPrefix(item.OriginalStoredPath, absDir+string(os.PathSeparator)) {
|
|
return fmt.Errorf("refusing to delete outside the upload directory")
|
|
}
|
|
if err := os.Remove(item.OriginalStoredPath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SanitiseFilename reduces a user-supplied filename to a safe display name.
|
|
//
|
|
// Only the base name survives, so "../../etc/passwd" becomes "passwd". Path
|
|
// separators, control characters, leading dots and Windows reserved names are
|
|
// all removed. The result is used for display and for choosing an extension —
|
|
// never as a path component on its own.
|
|
func SanitiseFilename(name string) string {
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
// Handle both separators: a Windows client may send backslashes.
|
|
name = strings.ReplaceAll(name, "\\", "/")
|
|
name = filepath.Base(name)
|
|
|
|
// filepath.Base returns "." or "/" for degenerate input.
|
|
if name == "." || name == ".." || name == "/" {
|
|
return ""
|
|
}
|
|
|
|
name = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r < 0x20 || r == 0x7f:
|
|
return -1
|
|
case r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' ||
|
|
r == '"' || r == '<' || r == '>' || r == '|' || r == 0:
|
|
return '_'
|
|
}
|
|
return r
|
|
}, name)
|
|
|
|
// A leading dot would create a hidden file and can hide the real extension.
|
|
name = strings.TrimLeft(name, ". ")
|
|
name = strings.TrimRight(name, ". ")
|
|
|
|
// Windows reserved device names, which can cause surprising behaviour if
|
|
// the file is ever copied to such a system.
|
|
base := name
|
|
if i := strings.IndexByte(name, '.'); i > 0 {
|
|
base = name[:i]
|
|
}
|
|
switch strings.ToUpper(base) {
|
|
case "CON", "PRN", "AUX", "NUL",
|
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
|
|
name = "file_" + name
|
|
}
|
|
|
|
if len(name) > 200 {
|
|
name = name[:200]
|
|
}
|
|
return name
|
|
}
|
|
|
|
func inferSourceType(mime string) domain.SourceType {
|
|
switch mime {
|
|
case "application/json":
|
|
return domain.SourceTypeJSON
|
|
case "text/markdown", "text/x-markdown":
|
|
return domain.SourceTypeMarkdown
|
|
case "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
|
return domain.SourceTypeDocument
|
|
default:
|
|
return domain.SourceTypeNote
|
|
}
|
|
}
|
|
|
|
func isTextual(mime string) bool {
|
|
switch mime {
|
|
case "text/plain", "text/markdown", "text/x-markdown", "application/json", "text/json", "text/csv":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isDOCX(mime string) bool {
|
|
return mime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
}
|
|
|
|
func stripControl(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
if r < 0x20 && r != '\n' && r != '\t' || r == 0x7f {
|
|
return -1
|
|
}
|
|
return r
|
|
}, strings.TrimSpace(s))
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
for n > 0 && !utf8.RuneStart(s[n]) {
|
|
n--
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|