Letzter vollständiger SSR-Stand vor dem Rückbau auf reine SPA. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
540 lines
16 KiB
Go
540 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// Serverseitig gerenderter Feed: das Gegenstück zu createFeed/renderEntry in
|
|
// web/js/feed.js. Beide holen dieselben Daten über feedPage; nur das Zeichnen
|
|
// passiert einmal in Go und einmal in JavaScript.
|
|
//
|
|
// Der Unterschied im Blättern ist Absicht: mit JavaScript hängt der Feed die
|
|
// nächste Seite unten an (Infinite Scroll), ohne JavaScript wechselt man auf
|
|
// eine eigene Seiten-URL. Nur so bleibt ein Anker (#e123) nach einer Aktion
|
|
// gültig -- bei akkumulierendem Nachladen wäre der Beitrag nach der Rückkehr
|
|
// gar nicht geladen.
|
|
|
|
// reContentURL entspricht dem Muster in feed.js (linkify): http/https bis zum
|
|
// nächsten Leerzeichen oder '<'. Das Schema ist Teil des Musters, deshalb kann
|
|
// hier kein javascript:-Link entstehen.
|
|
var reContentURL = regexp.MustCompile(`https?://[^\s<]+`)
|
|
|
|
// renderContent macht aus dem rohen Beitragstext sicheres HTML: alles wird
|
|
// escaped, danach werden nur die selbst erzeugten Links und <br> eingefügt.
|
|
// Reihenfolge ist wesentlich -- erst escapen, dann Markup ergänzen. Umgekehrt
|
|
// würde das Escapen die eigenen Tags wieder zerlegen.
|
|
func renderContent(s string) template.HTML {
|
|
var b strings.Builder
|
|
esc := func(part string) {
|
|
b.WriteString(strings.ReplaceAll(template.HTMLEscapeString(part), "\n", "<br>"))
|
|
}
|
|
|
|
last := 0
|
|
for _, m := range reContentURL.FindAllStringIndex(s, -1) {
|
|
esc(s[last:m[0]])
|
|
link := template.HTMLEscapeString(s[m[0]:m[1]])
|
|
b.WriteString(`<a href="` + link + `" target="_blank" rel="noopener">` + link + `</a>`)
|
|
last = m[1]
|
|
}
|
|
esc(s[last:])
|
|
return template.HTML(b.String())
|
|
}
|
|
|
|
// avatarURL spiegelt avatarUrl aus feed.js.
|
|
func avatarURL(path string) string {
|
|
if path == "" {
|
|
return "/static/assets/no_profile_pic.jpg"
|
|
}
|
|
return "/" + path
|
|
}
|
|
|
|
// entryView ist ein Beitrag mit allem, was die Vorlage zum Zeichnen braucht.
|
|
// Der Abstimmungsstand hängt am Betrachter, ReturnTo am Seitenaufruf --
|
|
// beides kann die Entry-Struktur aus der Datenbank nicht liefern.
|
|
type entryView struct {
|
|
Entry
|
|
tallyState
|
|
// ReturnTo ist auf einer Seite überall gleich; am Beitrag statt an der
|
|
// Seite, damit der entry-Block ohne Zugriff auf den Seitenkontext
|
|
// auskommt und auch einzeln (Thread-Ansicht) verwendbar bleibt.
|
|
ReturnTo string
|
|
LoggedIn bool
|
|
|
|
// EditURL ist gesetzt, wenn der Betrachter den Beitrag bearbeiten darf.
|
|
// Die Adresse trägt den Rückweg schon mit, damit das Speichern dorthin
|
|
// zurückführt, wo der Bearbeiten-Link angeklickt wurde -- und nicht auf
|
|
// die Bearbeitungsseite selbst.
|
|
EditURL string
|
|
// DeleteURL führt auf die Bestätigungsseite, nicht direkt aufs Löschen:
|
|
// ein Link darf nichts Unwiderrufliches auslösen (Vorschauen und Crawler
|
|
// folgen GET-Links ungefragt).
|
|
DeleteURL string
|
|
}
|
|
|
|
// bumpRemaining ist die Restsperre des Reposts in Sekunden (0 = wieder
|
|
// möglich). Spiegelt die Bedingung im UPDATE von handleBump; die Vorlage
|
|
// braucht den Wert, um den Repost-Knopf zu sperren und die Wartezeit
|
|
// anzuzeigen -- das Gegenstück zu setupBump in web/js/feed.js.
|
|
func (v entryView) bumpRemaining() int64 {
|
|
rem := v.LastBump + v.BumpCount*bumpStep - time.Now().Unix()
|
|
if rem < 0 {
|
|
return 0
|
|
}
|
|
return rem
|
|
}
|
|
|
|
// BumpLocked meldet, ob der Repost dieses Beitrags gerade im Cooldown steckt.
|
|
func (v entryView) BumpLocked() bool { return v.bumpRemaining() > 0 }
|
|
|
|
// BumpWaitHours ist die Restsperre auf volle Stunden aufgerundet (mindestens
|
|
// 1, solange überhaupt Zeit übrig ist) -- wie die Anzeige im JS-Frontend.
|
|
func (v entryView) BumpWaitHours() int64 {
|
|
h := (v.bumpRemaining() + 3599) / 3600
|
|
if h < 1 {
|
|
return 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
// composeForm hält die Eingaben des Beitragsformulars über einen
|
|
// fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei
|
|
// zwangsläufig: ein <input type="file"> lässt sich aus Sicherheitsgründen
|
|
// nicht vorbelegen, deshalb HadFile als Hinweis für den Nutzer.
|
|
type composeForm struct {
|
|
Content string
|
|
HadFile bool
|
|
}
|
|
|
|
// feedView ist das Datenmodell der Feed-Seite.
|
|
type feedView struct {
|
|
Entries []entryView
|
|
Page int // 0-basiert wie feedPage
|
|
Pages int // Gesamtzahl, mindestens 1
|
|
PrevURL string
|
|
NextURL string
|
|
|
|
// ReturnTo ist der Rücksprungort dieser Seite (beim GET die Seite selbst).
|
|
// Das Beitragsformular trägt ihn als verstecktes Feld, damit er einen
|
|
// abgelehnten Post übersteht -- sonst fiele das neu gerenderte Formular
|
|
// samt seiner Interaktions-Knöpfe auf den Default "/" zurück (die noch per
|
|
// JS bediente Startseite) statt auf "/feed".
|
|
ReturnTo string
|
|
|
|
// CanPost steuert, ob das Beitragsformular erscheint. Ohne Anmeldung gibt
|
|
// es nichts zu schreiben; über TOR wird das Schreiben abgelehnt (blockTOR),
|
|
// und ein Formular, das beim Absenden immer scheitert, wäre schlechter als
|
|
// ein ehrlicher Hinweis.
|
|
CanPost bool
|
|
IsTOR bool
|
|
Compose composeForm
|
|
}
|
|
|
|
// HumanPage ist die für Menschen gezählte Seitenzahl (ab 1).
|
|
func (v feedView) HumanPage() int { return v.Page + 1 }
|
|
|
|
// pageURL baut die URL einer Feed-Seite aus der aktuellen Anfrage. Über
|
|
// r.URL.Query() statt aus Einzelteilen, damit spätere Filter (Suche,
|
|
// Nutzer-Feed) automatisch erhalten bleiben.
|
|
func pageURL(r *http.Request, page int) string {
|
|
q := r.URL.Query()
|
|
if page <= 0 {
|
|
q.Del("page")
|
|
} else {
|
|
q.Set("page", strconv.Itoa(page))
|
|
}
|
|
if len(q) == 0 {
|
|
return r.URL.Path
|
|
}
|
|
return r.URL.Path + "?" + q.Encode()
|
|
}
|
|
|
|
// buildFeedView holt Beiträge und Gesamtzahl und baut die Blätter-Links.
|
|
func buildFeedView(r *http.Request, uid int64, rootsOnly bool) (feedView, error) {
|
|
// Bei GET steht die Seite in der Query. Wird der Feed nach einem
|
|
// fehlgeschlagenen POST neu gerendert, kommt sie aus dem Formular --
|
|
// sonst landet der Nutzer mit seinem Text unvermittelt auf Seite 1.
|
|
pageParam := r.URL.Query().Get("page")
|
|
if pageParam == "" {
|
|
pageParam = r.FormValue("page")
|
|
}
|
|
page, _ := strconv.Atoi(pageParam)
|
|
if page < 0 {
|
|
page = 0
|
|
}
|
|
if page > maxFeedPage {
|
|
page = maxFeedPage
|
|
}
|
|
|
|
total, err := feedTotal(uid, rootsOnly)
|
|
if err != nil {
|
|
return feedView{}, Internal(err)
|
|
}
|
|
pages := (total + feedPageSize - 1) / feedPageSize
|
|
if pages < 1 {
|
|
pages = 1
|
|
}
|
|
if pages > maxFeedPage+1 {
|
|
pages = maxFeedPage + 1
|
|
}
|
|
// Hinter der letzten Seite gäbe es nur eine leere Liste ohne Weg zurück.
|
|
if page > pages-1 {
|
|
page = pages - 1
|
|
}
|
|
|
|
entries, err := feedPage(page, uid, rootsOnly)
|
|
if err != nil {
|
|
return feedView{}, Internal(err)
|
|
}
|
|
|
|
// Der Betrachter ist nicht der gefilterte Nutzer: uid oben schränkt ein,
|
|
// wessen Beiträge gezeigt werden -- viewer ist, wer zusieht.
|
|
var viewer int64
|
|
if s, ok := getSession(r); ok {
|
|
viewer = s.UID
|
|
}
|
|
pids := make([]int64, 0, len(entries))
|
|
for _, e := range entries {
|
|
pids = append(pids, e.PID)
|
|
}
|
|
tallies, err := voteTallies(pids, viewer)
|
|
if err != nil {
|
|
return feedView{}, Internal(err)
|
|
}
|
|
|
|
back := returnTo(r)
|
|
v := feedView{
|
|
Entries: toEntryViews(entries, tallies, back, viewer),
|
|
Page: page,
|
|
Pages: pages,
|
|
IsTOR: isTOR(r),
|
|
ReturnTo: back,
|
|
}
|
|
v.CanPost = viewer != 0 && !v.IsTOR
|
|
if page > 0 {
|
|
v.PrevURL = pageURL(r, page-1)
|
|
}
|
|
if page < pages-1 {
|
|
v.NextURL = pageURL(r, page+1)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// threadView ist das Datenmodell der Thread-Seite.
|
|
type threadView struct {
|
|
Ancestors []entryView
|
|
Entry entryView
|
|
Replies []entryView
|
|
|
|
CanPost bool
|
|
IsTOR bool
|
|
Compose composeForm
|
|
|
|
// ReturnTo wie bei feedView: das Antwortformular trägt ihn versteckt mit,
|
|
// damit eine abgelehnte Antwort die Interaktions-Knöpfe nicht auf "/"
|
|
// zurückwirft statt auf den Thread.
|
|
ReturnTo string
|
|
}
|
|
|
|
// toEntryViews reichert Beiträge mit Abstimmungsstand, Rücksprungziel und
|
|
// Anmeldestatus an -- alles, was die Vorlage braucht und die Datenbank nicht
|
|
// liefert. Feed und Thread teilen sich den Schritt.
|
|
func toEntryViews(entries []Entry, tallies map[int64]tallyState, back string, viewer int64) []entryView {
|
|
views := make([]entryView, 0, len(entries))
|
|
for _, e := range entries {
|
|
v := entryView{
|
|
Entry: e,
|
|
tallyState: tallies[e.PID],
|
|
ReturnTo: back,
|
|
LoggedIn: viewer != 0,
|
|
}
|
|
// Gelöschte Beiträge haben uid 0 -- ohne die Deleted-Prüfung wäre der
|
|
// Platzhalter für einen nicht angemeldeten Betrachter "eigener"
|
|
// Beitrag. viewer != 0 fängt das zwar schon ab, die Prüfung bleibt
|
|
// trotzdem stehen: an einem [deleted] gibt es nichts zu bearbeiten.
|
|
if viewer != 0 && e.UID == viewer && e.Deleted == 0 {
|
|
v.EditURL = ownActionURL("bearbeiten", e.PID, back)
|
|
v.DeleteURL = ownActionURL("loeschen", e.PID, back)
|
|
}
|
|
views = append(views, v)
|
|
}
|
|
return views
|
|
}
|
|
|
|
// ownActionURL verlinkt eine Seite für den eigenen Beitrag (bearbeiten,
|
|
// loeschen) und hängt den Rückweg als Query an. url.Values kodiert dabei auch
|
|
// das '#' des Ankers korrekt -- von Hand zusammengesetzt würde der Anker die
|
|
// Query zerschneiden.
|
|
func ownActionURL(action string, pid int64, back string) string {
|
|
q := url.Values{"return_to": {fmt.Sprintf("%s#e%d", back, pid)}}
|
|
return fmt.Sprintf("/beitrag/%d/%s?%s", pid, action, q.Encode())
|
|
}
|
|
|
|
// buildThreadView holt den Thread und bereitet ihn zum Zeichnen auf.
|
|
func buildThreadView(r *http.Request, pid int64) (threadView, error) {
|
|
t, err := threadData(pid)
|
|
if err != nil {
|
|
return threadView{}, err
|
|
}
|
|
|
|
var viewer int64
|
|
if s, ok := getSession(r); ok {
|
|
viewer = s.UID
|
|
}
|
|
|
|
// Alle Beiträge der Seite in einem Rutsch -- Ahnen, der Beitrag selbst und
|
|
// die Antworten.
|
|
pids := make([]int64, 0, len(t.Ancestors)+1+len(t.Replies))
|
|
pids = append(pids, t.Entry.PID)
|
|
for _, e := range t.Ancestors {
|
|
pids = append(pids, e.PID)
|
|
}
|
|
for _, e := range t.Replies {
|
|
pids = append(pids, e.PID)
|
|
}
|
|
tallies, err := voteTallies(pids, viewer)
|
|
if err != nil {
|
|
return threadView{}, Internal(err)
|
|
}
|
|
|
|
back := returnTo(r)
|
|
v := threadView{
|
|
Ancestors: toEntryViews(t.Ancestors, tallies, back, viewer),
|
|
Entry: toEntryViews([]Entry{t.Entry}, tallies, back, viewer)[0],
|
|
Replies: toEntryViews(t.Replies, tallies, back, viewer),
|
|
IsTOR: isTOR(r),
|
|
ReturnTo: back,
|
|
}
|
|
// Auf einen gelöschten Beitrag lässt sich nicht antworten -- er hat keinen
|
|
// Autor mehr und der Platzhalter soll den Thread nur zusammenhalten.
|
|
v.CanPost = viewer != 0 && !v.IsTOR && t.Entry.Deleted == 0
|
|
return v, nil
|
|
}
|
|
|
|
// showThread rendert einen Beitrag mit Ahnenkette, Antworten und Antwortformular.
|
|
func showThread(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, err := buildThreadView(r, pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
render(w, r, "thread", pageData{Title: "Beitrag", Form: v})
|
|
return nil
|
|
}
|
|
|
|
// editView füttert die Bearbeitungsseite. Anders als beim Erstellen ist der
|
|
// Ausgangszustand nicht leer, sondern der bisherige Text.
|
|
type editView struct {
|
|
PID int64
|
|
Content string
|
|
BackURL string
|
|
}
|
|
|
|
// ownEntry lädt einen Beitrag und prüft, ob er dem Anfragenden gehört --
|
|
// Voraussetzung fürs Bearbeiten wie fürs Löschen. Bewusst dieselbe Prüfkette
|
|
// wie in den schreibenden Handlern: eine Seite anzuzeigen, die beim Absenden
|
|
// abgelehnt wird, wäre irreführend.
|
|
func ownEntry(r *http.Request, pid int64) (Entry, error) {
|
|
e, err := entryByPID(pid)
|
|
if err != nil {
|
|
return Entry{}, errEntryNotFound
|
|
}
|
|
// Soft-gelöschte Beiträge stehen auf uid 0 und scheitern damit an derselben
|
|
// Besitzprüfung -- sie gehören niemandem mehr. Kein eigener Zweig dafür:
|
|
// so bleibt es bei den 403, die der Endpunkt schon immer geliefert hat.
|
|
if e.UID != uidFromContext(r.Context()) {
|
|
return Entry{}, errEntryNotOwned
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// showEditEntry zeigt das Bearbeitungsformular mit dem bisherigen Text.
|
|
func showEditEntry(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e, err := ownEntry(r, pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
render(w, r, "edit", pageData{
|
|
Title: "Beitrag bearbeiten",
|
|
Form: editView{
|
|
PID: pid,
|
|
Content: e.Content,
|
|
BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)),
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// failEdit zeichnet das Bearbeitungsformular nach einem Fehler neu -- mit der
|
|
// abgelehnten Fassung, nicht mit dem gespeicherten Stand. Sonst verlöre der
|
|
// Nutzer genau die Änderung, die er gerade vorgenommen hat.
|
|
func failEdit(w http.ResponseWriter, r *http.Request, pid int64, content string, err error) error {
|
|
e := asError(err)
|
|
if e.Kind == KindInternal {
|
|
renderError(w, e)
|
|
return nil
|
|
}
|
|
failForm(w, r, "edit", pageData{
|
|
Title: "Beitrag bearbeiten",
|
|
Form: editView{
|
|
PID: pid,
|
|
Content: content,
|
|
BackURL: safeReturn(r.FormValue("return_to"), threadAnchor(pid)),
|
|
},
|
|
}, e)
|
|
return nil
|
|
}
|
|
|
|
// threadAnchor ist der Standard-Rückweg zu einem Beitrag.
|
|
func threadAnchor(pid int64) string {
|
|
return fmt.Sprintf("/beitrag/%d#e%d", pid, pid)
|
|
}
|
|
|
|
// deleteView füttert die Löschbestätigung. Sie zeigt den Beitrag noch einmal,
|
|
// damit erkennbar ist, was verschwindet.
|
|
type deleteView struct {
|
|
PID int64
|
|
Content string
|
|
HasImage bool
|
|
IsRoot bool
|
|
Replies int64
|
|
BackURL string
|
|
}
|
|
|
|
// showDeleteEntry fragt vor dem Löschen nach. Eigene Seite statt eines Links,
|
|
// der direkt löscht: ein GET darf nichts Unwiderrufliches tun, und ohne
|
|
// JavaScript gibt es kein confirm().
|
|
func showDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e, err := ownEntry(r, pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
render(w, r, "delete", pageData{
|
|
Title: "Beitrag löschen",
|
|
Form: deleteView{
|
|
PID: pid,
|
|
Content: e.Content,
|
|
HasImage: e.Filepath != "",
|
|
IsRoot: e.ReplyTo == 0,
|
|
Replies: e.ReplyCount,
|
|
BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)),
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// failCompose zeichnet den Feed nach einem abgelehnten Beitrag neu -- mit
|
|
// Fehlermeldung und dem bereits getippten Text. Ohne JavaScript gibt es keinen
|
|
// Weg, nur einen Teil der Seite auszutauschen; der Text darf dabei trotzdem
|
|
// nicht verloren gehen.
|
|
// Antworten werden im Thread neu gezeichnet, neue Beiträge im Feed -- der
|
|
// Nutzer soll dort landen, wo er geschrieben hat.
|
|
func failCompose(w http.ResponseWriter, r *http.Request, replyTo int64, form composeForm, err error) error {
|
|
e := asError(err)
|
|
if e.Kind == KindInternal {
|
|
renderError(w, e)
|
|
return nil
|
|
}
|
|
|
|
if replyTo != 0 {
|
|
v, verr := buildThreadView(r, replyTo)
|
|
if verr != nil {
|
|
// Gibt es den Elternbeitrag nicht (mehr), lässt sich der Thread
|
|
// nicht zeichnen -- dann bleibt nur die Fehlerseite.
|
|
return verr
|
|
}
|
|
v.Compose = form
|
|
failForm(w, r, "thread", pageData{Title: "Beitrag", Form: v}, e)
|
|
return nil
|
|
}
|
|
|
|
v, verr := buildFeedView(r, 0, true)
|
|
if verr != nil {
|
|
// Wenn schon der Feed nicht mehr geht, ist der Beitragsfehler das
|
|
// kleinere Problem -- dann die Fehlerseite zeigen.
|
|
return verr
|
|
}
|
|
v.Compose = form
|
|
|
|
failForm(w, r, "feed", pageData{Title: "Feed", Form: v}, e)
|
|
return nil
|
|
}
|
|
|
|
// showFeed rendert den Hauptfeed (nur Wurzelbeiträge, nach Aktivität sortiert).
|
|
func showFeed(w http.ResponseWriter, r *http.Request) error {
|
|
v, err := buildFeedView(r, 0, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
render(w, r, "feed", pageData{Title: "Feed", Form: v})
|
|
return nil
|
|
}
|
|
|
|
// showUserFeed rendert den Feed eines einzelnen Nutzers (inkl. Antworten).
|
|
func showUserFeed(w http.ResponseWriter, r *http.Request) error {
|
|
username := chi.URLParam(r, "username")
|
|
|
|
var uid int64
|
|
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, username).Scan(&uid); err != nil {
|
|
return errUserNotFound
|
|
}
|
|
|
|
v, err := buildFeedView(r, uid, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
render(w, r, "userfeed", pageData{
|
|
Title: username,
|
|
Form: userFeedView{feedView: v, Username: username},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// userFeedView ergänzt den Feed um den Nutzer, dessen Beiträge gezeigt werden.
|
|
type userFeedView struct {
|
|
feedView
|
|
Username string
|
|
}
|
|
|
|
// profileURL escaped den Namen für den Pfad. chi liefert ihn roh; ohne
|
|
// Kodierung würden Namen mit '.' oder '-' zwar funktionieren, ein '/' oder '?'
|
|
// im Namen aber die URL zerlegen.
|
|
func profileURL(username string) string {
|
|
return "/u/" + url.PathEscape(username)
|
|
}
|
|
|
|
// fmtTime formatiert einen Unix-Zeitstempel für die Anzeige. Ohne JavaScript
|
|
// gibt es keine Client-Zeitzone, deshalb UTC mit ausgeschriebenem Kürzel --
|
|
// lieber eindeutig als scheinbar lokal.
|
|
func fmtTime(unix int64) string {
|
|
return time.Unix(unix, 0).UTC().Format("02.01.2006, 15:04 UTC")
|
|
}
|
|
|
|
// fmtTimeAttr liefert dieselbe Zeit maschinenlesbar für <time datetime>.
|
|
// Damit kann eine spätere JS-Schicht sie in Ortszeit umschreiben, ohne dass
|
|
// die Seite ohne JavaScript etwas verliert.
|
|
func fmtTimeAttr(unix int64) string {
|
|
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
|
|
}
|