Compare commits

..

10 Commits

Author SHA1 Message Date
irrlicht 13363cb568 Mailadresse aktualisiert 2026-07-01 10:08:34 +02:00
irrlicht e4389de288 Image from clipboard also in replies 2026-06-29 00:16:58 +02:00
irrlicht aa0def2949 Sigils added to rule page 2026-06-28 19:57:52 +02:00
irrlicht ba0d0d62f2 Metaline in deleted posts 2026-06-24 10:24:26 +02:00
irrlicht 32c70ad0f8 New Meta-Line 2026-06-24 10:21:40 +02:00
irrlicht 112e1f82ca Metaline styling 2026-06-24 09:46:10 +02:00
irrlicht 308eec734f Buttons neu sortiert 2026-06-24 09:34:00 +02:00
irrlicht 1cfbb8b8fe Block requests from TOR 2026-06-23 18:26:51 +02:00
irrlicht 01be0c523e Repost 2026-06-22 11:40:27 +02:00
irrlicht e965775809 revert migration 2026-06-19 23:07:18 +02:00
18 changed files with 371 additions and 101 deletions
+26 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
"strings"
_ "modernc.org/sqlite"
)
@@ -38,7 +39,9 @@ CREATE TABLE IF NOT EXISTS entry (
reply_to INTEGER NOT NULL DEFAULT 0,
reply_count INTEGER NOT NULL DEFAULT 0,
last_activity INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0
deleted INTEGER NOT NULL DEFAULT 0,
bump_count INTEGER NOT NULL DEFAULT 0,
last_bump INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS vote (
@@ -86,6 +89,26 @@ func initDB(dsn string) error {
if err = db.Ping(); err != nil {
return err
}
_, err = db.Exec(schema)
return err
if _, err = db.Exec(schema); err != nil {
return err
}
return migrate()
}
// migrate ergänzt Spalten, die nach dem ersten Deploy dazukamen. CREATE TABLE IF
// NOT EXISTS fasst eine bestehende Tabelle nicht an -> neue Spalten brauchen ein
// ALTER. SQLite kann ADD COLUMN nicht "IF NOT EXISTS", deshalb schlucken wir den
// "duplicate column"-Fehler: bei einer frischen DB sind die Spalten schon aus
// dem Schema da, bei einer alten werden sie hier nachgezogen.
func migrate() error {
alters := []string{
`ALTER TABLE entry ADD COLUMN bump_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE entry ADD COLUMN last_bump INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range alters {
if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
}
}
return nil
}
+85
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
)
@@ -221,6 +222,54 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
}
}
func TestBump(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
bob := registerAndLogin(t, srv, "bob")
pid := createEntry(t, alice, srv, "bump me")
bumpURL := fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid)
// Anonym: 401.
resp := postForm(t, newClient(t), bumpURL, url.Values{})
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anon bump: erwartet 401, bekam %d", resp.StatusCode)
}
// Erster Bump (bump_count 0 -> kein Cooldown): 200, danach bump_count 1.
resp = postForm(t, alice, bumpURL, url.Values{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("erster bump: erwartet 200, bekam %d", resp.StatusCode)
}
var out struct {
BumpCount int64 `json:"bump_count"`
RetryAfter int64 `json:"retry_after"`
}
json.NewDecoder(resp.Body).Decode(&out)
if out.BumpCount != 1 || out.RetryAfter != 86400 {
t.Fatalf("nach erstem bump: %+v", out)
}
// Cooldown gilt pro Beitrag (global): auch ein anderer Nutzer wird sofort
// abgewiesen.
resp = postForm(t, bob, bumpURL, url.Values{})
resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("bump im cooldown: erwartet 429, bekam %d", resp.StatusCode)
}
// Gelöschter Beitrag lässt sich nicht bumpen.
del := createEntry(t, alice, srv, "weg gleich")
resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, del), url.Values{})
resp.Body.Close()
resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, del), url.Values{})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode)
}
}
// createReply erstellt eine Antwort auf parentPID und liefert deren pid.
func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
t.Helper()
@@ -504,3 +553,39 @@ func TestDeleteUser(t *testing.T) {
t.Fatalf("bobs antwort verwaist: %+v", tv.Replies)
}
}
// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP
// überschreibt damit r.RemoteAddr, sodass blockTOR die simulierte Quell-IP sieht.
func postFormFrom(t *testing.T, c *http.Client, urlStr, realIP string, form url.Values) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(form.Encode()))
if err != nil {
t.Fatalf("request %s: %v", urlStr, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Real-IP", realIP)
resp, err := c.Do(req)
if err != nil {
t.Fatalf("POST %s: %v", urlStr, err)
}
return resp
}
// TestBlockTOREntry prüft, dass Beiträge aus dem TOR-Netz (Default 10.89.0.0/16)
// mit 403 abgewiesen werden, aus einem anderen Netz aber durchgehen.
func TestBlockTOREntry(t *testing.T) {
srv := newTestServer(t)
c := registerAndLogin(t, srv, "toruser")
resp := postFormFrom(t, c, srv.URL+"/entry/create", "10.89.1.2", url.Values{"content": {"aus tor"}})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("TOR-Beitrag: erwartet 403, bekam %d", resp.StatusCode)
}
resp = postFormFrom(t, c, srv.URL+"/entry/create", "203.0.113.7", url.Values{"content": {"normal"}})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode)
}
}
+67 -2
View File
@@ -30,6 +30,8 @@ type Entry struct {
ReplyCount int64 `json:"reply_count"`
LastActivity int64 `json:"last_activity"`
Deleted int64 `json:"deleted"`
BumpCount int64 `json:"bump_count"`
LastBump int64 `json:"last_bump"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
@@ -39,6 +41,7 @@ type Entry struct {
const entrySelect = `
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN user u ON u.uid = e.uid`
@@ -49,7 +52,8 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
for rows.Next() {
var e Entry
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar); err != nil {
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil {
return nil, err
}
entries = append(entries, e)
@@ -62,7 +66,8 @@ func entryByPID(pid int64) (Entry, error) {
var e Entry
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar)
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
return e, err
}
@@ -341,6 +346,66 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump
// ein Tag Sperre, nach dem 10. Bump zehn Tage. So wird Hochspülen mit jedem Mal
// teurer.
const bumpStep = 24 * 60 * 60
// handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed
// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads
// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep);
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
func handleBump(w http.ResponseWriter, r *http.Request) {
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige pid")
return
}
var bumpCount, lastBump, deleted int64
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid).
Scan(&bumpCount, &lastBump, &deleted); err != nil {
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
return
}
if deleted != 0 {
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
return
}
now := time.Now().Unix()
// Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bumpCount 0) ist die
// Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump.
if remaining := lastBump + bumpCount*bumpStep - now; remaining > 0 {
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": "Dieser Beitrag wurde erst kürzlich repostet.",
"retry_after": remaining,
"bump_count": bumpCount,
})
return
}
if _, err := db.Exec(
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? WHERE pid = ?`,
now, now, pid,
); err != nil {
writeError(w, http.StatusInternalServerError, "Bumpen fehlgeschlagen")
return
}
bumpCount++
writeJSON(w, http.StatusOK, map[string]any{
"pid": pid,
"bump_count": bumpCount,
"last_bump": now,
"last_activity": now,
"retry_after": bumpCount * bumpStep, // bis zum nächsten erlaubten Bump
})
}
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func voteTally(pid, uid int64) (left, right int, selected string) {
+2 -5
View File
@@ -26,10 +26,6 @@ func main() {
initGeoASN()
defer closeGeoASN()
// TEMPORÄR: einmalig die Bestands-Impressions ohne asn nachträglich auflösen.
// Nach einem erfolgreichen Lauf wieder entfernen/reverten (samt backfillASN).
backfillASN()
srv := &http.Server{
Addr: envOr("KVER_ADDR", ":8080"),
Handler: routes(),
@@ -117,8 +113,9 @@ func routes() http.Handler {
// --- Geschützt (Session erforderlich) ---
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Post("/entry/create", handleCreateEntry)
r.With(blockTOR).Post("/entry/create", handleCreateEntry)
r.Post("/entry/{pid}/vote", handleVote)
r.Post("/entry/{pid}/bump", handleBump)
r.Post("/entry/{pid}/edit", handleEditEntry)
r.Post("/entry/{pid}/delete", handleDeleteEntry)
r.Get("/user/info", handleUserInfo)
+37
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"log"
"net"
"net/http"
)
@@ -9,6 +11,41 @@ type ctxKey string
const uidKey ctxKey = "uid"
// torNet ist das interne Netz, über das TOR-Anfragen beim Backend ankommen:
// der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte
// Client-IP gibt es dabei nicht -> die Anfragen erscheinen mit einer IP aus
// diesem Netz (siehe Impression-Log: 10.89.0.0). Beiträge von dort werden
// vorerst komplett abgewiesen; eine Moderations-/Freigabeseite kommt später.
// Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab.
var torNet *net.IPNet
func init() {
cidr := envOr("KVER_TOR_NET", "10.89.0.0/16")
if cidr == "" {
return
}
if _, n, err := net.ParseCIDR(cidr); err == nil {
torNet = n
} else {
log.Printf("KVER_TOR_NET ignoriert (ungültiges CIDR %q): %v", cidr, err)
}
}
// blockTOR weist Anfragen aus dem TOR-Netz ab (siehe torNet). Die volle IP wird
// hier nur für diese Zugriffsentscheidung geprüft, nicht gespeichert oder
// geloggt. Ist kein Netz konfiguriert, ist die Middleware ein No-op.
func blockTOR(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if torNet != nil {
if ip := clientIP(r.RemoteAddr); ip != nil && torNet.Contains(ip) {
writeError(w, http.StatusForbidden, "Beiträge über TOR sind derzeit nicht möglich.")
return
}
}
next.ServeHTTP(w, r)
})
}
// requireAuth lehnt Anfragen ohne gültige Session ab und legt die uid in den Context.
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -0,0 +1 @@
<svg width="620" height="620" xmlns="http://www.w3.org/2000/svg" stroke="#000" stroke-width="62" fill="none"><circle cx="310" cy="310" r="269"/><path d="m113 493 198-412 125 256h-248 248l77 156"/></svg>

After

Width:  |  Height:  |  Size: 202 B

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.0" width="625" height="625" id="svg3112">
<defs id="defs3114"/>
<g transform="translate(-342.7895,60.42734)" id="layer1">
<path d="M 655.25597,-60.42734 L 618.47153,32.87626 L 644.51595,32.87626 L 644.51595,156.05047 C 625.91952,158.11289 608.93417,165.49877 595.04496,176.59066 L 507.91674,89.46256 L 526.30905,71.070348 L 434.28074,31.130998 L 474.2201,123.09215 L 492.67945,104.69994 L 579.74043,191.76091 C 568.62507,205.64866 561.27672,222.69194 559.20024,241.29914 L 436.09314,241.29914 L 436.09314,215.25462 L 342.78953,252.10619 L 436.09314,288.8906 L 436.09314,262.84619 L 559.20024,262.84619 C 561.26663,281.4792 568.61197,298.5473 579.74043,312.45146 L 492.67945,399.51252 L 474.2201,381.12031 L 434.28074,473.08136 L 526.30905,433.14211 L 507.91674,414.7498 L 595.04496,327.6217 C 608.93417,338.71369 625.91952,346.09957 644.51595,348.16199 L 644.51595,471.26897 L 618.47153,471.26897 L 655.25597,564.57266 L 692.10753,471.26897 L 666.06311,471.26897 L 666.06311,348.16199 C 684.6703,346.08541 701.71349,338.73706 715.60124,327.6217 L 802.66232,414.68276 L 784.27001,433.14211 L 876.23118,473.08136 L 836.29182,381.05317 L 817.89961,399.44539 L 730.77149,312.31729 C 741.86348,298.42807 749.24937,281.44262 751.31168,262.84619 L 874.48592,262.84619 L 874.48592,288.8906 L 967.78953,252.10619 L 874.48592,215.25462 L 874.48592,241.29914 L 751.31168,241.29914 C 749.23928,222.72841 741.85028,205.7679 730.77149,191.89517 L 817.89961,104.76707 L 836.29182,123.15929 L 876.23118,31.130998 L 784.27001,71.070348 L 802.66232,89.529698 L 715.60124,176.59066 C 701.71349,165.4753 684.6703,158.12695 666.06311,156.05047 L 666.06311,32.87626 L 692.10753,32.87626 L 655.25597,-60.42734 z " style="fill:black;fill-opacity:1;stroke:none;stroke-dasharray:none;stroke-opacity:1" id="circle2034"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

+7 -2
View File
@@ -15,7 +15,12 @@
<a href="/stats.html">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
</header>
<div id="sigils" aria-hidden="true">
<img src="/static/assets/sigils/anarchy_a.svg" alt="">
<img src="/static/assets/sigils/chaos_star.svg" alt="">
<img src="/static/assets/sigils/sab_cat.svg" alt="">
</div>
</header>
<main>
<div class="doc">
@@ -143,7 +148,7 @@ Inhalte, die gegen deutsches Recht verstoßen, sind verboten. Dazu gehören insb
<li>Temporäre Sperre</li>
<li>Permanente Sperre</li>
</ol>
<em>Einspruch:</em> Per E-Mail an <a>david@bernet.it</a>
<em>Einspruch:</em> Per E-Mail an <a>stress@bnd.wtf</a>
</p>
<p><strong>§10 Änderungen</strong><br><br>
+1 -1
View File
@@ -27,7 +27,7 @@ Merseburger Str. 108<br>
06110 Halle (Saale)<br>
<br>
Telefon: +49-15782760787<br>
E-Mail: <a>david@bernet.it</a>
E-Mail: <a>stress@bnd.wtf</a>
</p>
<p><strong>Übersicht der erfassten Daten</strong></p>
+1 -1
View File
@@ -31,7 +31,7 @@ David Bernet<br>
</p>
<p><strong>Kontakt:</strong> <br>
Telefon: +49-15782760787<br>
E-Mail: <a>david@bernet.it</a></br></p>
E-Mail: <a>stress@bnd.wtf</a></br></p>
<p><strong>Verbraucherstreitbeilegung / Universalschlichtungsstelle</strong>
<br>Wir nehmen nicht an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teil und sind dazu auch nicht verpflichtet.
</p>
-45
View File
@@ -147,51 +147,6 @@ func lookupASN(ip net.IP) string {
return label
}
// backfillASN setzt das asn-Feld für bereits erfasste Impressions nach, die noch
// keins haben (asn = ''). Nötig, weil recordImpression das asn nur beim ersten
// Insert pro (Tag, Netz, Pfad) schreibt -> Zeilen, die vor aktiver ASN-DB
// entstanden, blieben "(unbekannt)".
//
// TEMPORÄR: einmalig beim Start ausführen, danach den Aufruf (und diese Funktion)
// wieder entfernen/reverten. Arbeitet nur auf dem bereits anonymisierten Netz
// (anon_ip), nie auf einer vollen IP.
func backfillASN() {
if geoASN == nil {
return
}
rows, err := db.Query(`SELECT DISTINCT anon_ip FROM impression WHERE asn = ''`)
if err != nil {
log.Printf("ASN-Backfill: Abfrage fehlgeschlagen: %v", err)
return
}
var nets []string
for rows.Next() {
var anon string
if rows.Scan(&anon) == nil && anon != "" {
nets = append(nets, anon)
}
}
rows.Close()
var updated, resolved int
for _, anon := range nets {
asn := lookupASN(net.ParseIP(anon))
if asn == "" {
continue // kein Treffer -> bleibt "(unbekannt)"
}
res, err := db.Exec(`UPDATE impression SET asn = ? WHERE anon_ip = ? AND asn = ''`, asn, anon)
if err != nil {
log.Printf("ASN-Backfill: Update für %s fehlgeschlagen: %v", anon, err)
continue
}
resolved++
if n, _ := res.RowsAffected(); n > 0 {
updated += int(n)
}
}
log.Printf("ASN-Backfill: %d Netze aufgelöst, %d Zeilen aktualisiert (von %d offenen Netzen)", resolved, updated, len(nets))
}
// trackImpression zählt einen Seitenaufruf pro (Tag, anonymisiertes Netz, Pfad)
// und vermerkt das autonome System. Die IP wird zuerst maskiert; sowohl die
// Speicherung als auch die ASN-Auflösung arbeiten nur auf dem anonymisierten
+1 -1
View File
@@ -1,6 +1,6 @@
# Kontrollverlust — Responsible Disclosure
# Siehe auch Beitragsordnung §7 (Technische Regeln).
Contact: mailto:david@bernet.it
Contact: mailto:stress@bnd.wtf
Expires: 2027-06-15T00:00:00.000Z
Preferred-Languages: de, en
Canonical: https://kver.xyz/.well-known/security.txt
+42 -3
View File
@@ -87,6 +87,23 @@ h2 {
color: black;
}
/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe,
gedämpft per opacity (die SVGs sind teils Strich-, teils Flächenzeichnungen,
eine gemeinsame Farbe gäbe es nicht -> Transparenz vereinheitlicht den Look).
aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
#sigils {
display: flex;
justify-content: center;
align-items: center;
gap: 1.2rem;
margin: 0.3rem 0;
}
#sigils img {
height: 50px;
width: auto;
}
#headerbar {
background-color: white;
text-align: center;
@@ -203,13 +220,23 @@ img.avatar {
img.avatar-sm { width: var(--avatar-sm); height: var(--avatar-sm); }
img.avatar-lg { width: 72px; height: 72px; }
/* Byline mit kleinem Avatar neben Name + Zeitstempel. */
/* Byline mit kleinem Avatar neben Name + Zeitstempel. Einheitliche 1rem für die
ganze Meta-Zeile (Name, Datum, Repost-Cooldown), damit nichts größer rausfällt
als die Links -- der Klartext würde sonst die 1.2rem der Card erben. */
.byline {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
/* Versuchsweise die Titel-Display-Schrift (wie h1/h2) in der Meta-Zeile. */
font-family: "Cheltenham Classic", serif;
}
/* Die globale Regel `span, a { font-size: 1.2rem }` trifft das Datum/Cooldown-
span direkt und macht es größer als den Username-Link (der über `.muted a`
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
.byline span { font-size: 1rem; }
/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */
.profile-head {
display: flex;
@@ -244,14 +271,26 @@ img.avatar-lg { width: 72px; height: 72px; }
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */
#ancestors .threadline { border-right: var(--line-w) solid gray; }
/* Interaktionsleiste: Vote-Buttons zentriert (das Antworten-Label sitzt jetzt
oben in der Byline). */
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
wandert in die Byline (siehe setupBump). */
.interactions {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 0.25rem;
}
/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown
gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */
.bump:disabled {
color: gray;
border-color: #ccc;
cursor: default;
text-decoration: none;
}
.row {
display: flex;
align-items: center;
+1
View File
@@ -26,6 +26,7 @@
<section id="reply-box" hidden>
<form id="reply-form">
<textarea name="content" rows="3" maxlength="1000" placeholder="Antworten…"></textarea>
<p class="muted" id="reply-paste-msg"></p>
<input name="file" type="file" accept="image/*">
<button type="submit">Antworten</button>
</form>
+7 -33
View File
@@ -97,51 +97,25 @@ document.getElementById("entry-form").addEventListener("submit", async (e) => {
});
// --- Bild aus der Zwischenablage einfügen ---
// Strg+V im Textfeld: liegt ein Bild in der Zwischenablage, übernehmen wir es in
// den File-Input (über DataTransfer, weil sich .files nicht direkt setzen lässt).
// Liegt kein Bild vor (Text o. Ä.), tun wir nichts und lassen den normalen Paste
// durchlaufen -> preventDefault nur, wenn wir wirklich ein Bild gegriffen haben.
document.getElementById("entry-form").addEventListener("paste", (e) => {
const item = [...(e.clipboardData?.items ?? [])].find(
(it) => it.kind === "file" && it.type.startsWith("image/")
);
if (!item) return;
const original = item.getAsFile();
if (!original) return;
e.preventDefault();
// Browser benennt Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) -> neu
// verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
const ext = (original.type.split("/")[1] || "png").toLowerCase();
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
const input = document.querySelector("#entry-form input[name='file']");
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
document.getElementById("paste-msg").textContent = "Bild aus Zwischenablage übernommen.";
});
setupClipboardPaste(
document.getElementById("entry-form"),
document.getElementById("paste-msg")
);
// --- Statuszeile unter dem Titel ---
// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
// Version) plus aktuelle Kennzahlen aus /stats.
// Version).
const dataLossStart = new Date("2025-12-13T12:44:00").getTime();
function plural(n, one, many) {
return `${n} ${n === 1 ? one : many}`;
}
async function renderStats() {
function renderStats() {
const el = document.getElementById("stats");
if (!el) return;
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
const s = await api.get("/stats");
el.textContent =
`Ohne Datenverlust seit ${plural(days, "Tag", "Tagen")} · ` +
`${plural(s.users ?? 0, "Profil", "Profile")} · ` +
`${plural(s.entries ?? 0, "Beitrag", "Beiträge")}`;
el.textContent = `Ohne Datenverlust seit ${plural(days, "Tag", "Tagen")}`;
}
// --- Start ---
+5 -1
View File
@@ -3,7 +3,10 @@
// pid aus dem Pfad /e/<pid>.
const pid = location.pathname.split("/")[2] || "";
document.getElementById("reply-form").addEventListener("submit", async (e) => {
const replyForm = document.getElementById("reply-form");
setupClipboardPaste(replyForm, document.getElementById("reply-paste-msg"));
replyForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
fd.append("reply_to", pid);
@@ -11,6 +14,7 @@ document.getElementById("reply-form").addEventListener("submit", async (e) => {
if (res.ok) {
e.target.reset();
document.getElementById("reply-msg").textContent = "";
document.getElementById("reply-paste-msg").textContent = "";
load();
} else {
document.getElementById("reply-msg").textContent =
+74 -4
View File
@@ -40,18 +40,46 @@ function linkify(s) {
.replace(/\n/g, "<br>");
}
// setupClipboardPaste erlaubt Strg+V eines Bildes aus der Zwischenablage direkt
// im Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich
// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir
// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn
// wir wirklich ein Bild gegriffen haben. msgEl (optional) zeigt den Hinweis.
function setupClipboardPaste(form, msgEl) {
form.addEventListener("paste", (e) => {
const item = [...(e.clipboardData?.items ?? [])].find(
(it) => it.kind === "file" && it.type.startsWith("image/")
);
if (!item) return;
const original = item.getAsFile();
if (!original) return;
e.preventDefault();
// Browser benennt Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) -> neu
// verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
const ext = (original.type.split("/")[1] || "png").toLowerCase();
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
const input = form.querySelector("input[name='file']");
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
if (msgEl) msgEl.textContent = "Bild aus Zwischenablage übernommen.";
});
}
function renderEntry(e) {
const el = document.createElement("article");
el.className = "card";
const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
const replyLabel =
e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`;
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
el.innerHTML = `
<div class="muted byline"><span>[deleted] · ${when} · <a href="/e/${e.pid}">${replyLabel}</a></span></div>
<div class="muted byline"><span>[deleted] · ${when}</span></div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
@@ -71,10 +99,12 @@ function renderEntry(e) {
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
}
// Antwort- und Repost-Zahl wandern in die Buttons unten; in der Byline bleibt
// nur Name, Zeit und (im Cooldown) der Hinweis bis zum nächsten Repost.
el.innerHTML = `
<div class="muted byline">
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when} · <a href="/e/${e.pid}">${replyLabel}</a></span>
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span>
</div>
<div class="threadline-wrapper">
<div class="threadline"></div>
@@ -82,16 +112,19 @@ function renderEntry(e) {
<div class="content">${linkify(e.content)}</div>
${media}
<div class="interactions">
<a class="button reply" href="/e/${e.pid}">Antworten (${e.reply_count})</a>
<div class="row">
<button class="ghost" data-mode="left">Links</button>
<span class="muted"> / </span>
<button class="ghost" data-mode="right">Rechts</button>
</div>
<button class="bump" type="button">Repost (${e.bump_count})</button>
</div>
</div>
</div>
`;
setupVoting(el, e.pid);
setupBump(el, e);
el.addEventListener("click", (ev) => {
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
});
@@ -126,6 +159,43 @@ function setupVoting(el, pid) {
api.get(`/entry/${pid}/votes`).then(render);
}
// setupBump verdrahtet den Bump-Button. Der Cooldown wächst pro Beitrag linear
// (Backend, siehe handleBump); hier wird nur angezeigt und auf 429 reagiert. Eine
// Live-Aktualisierung gibt es bewusst nicht: der Zustand stammt aus den
// Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks.
function setupBump(el, e) {
const btn = el.querySelector(".bump");
if (!btn) return;
const cooldown = el.querySelector(".cooldown");
// sekunden bis zum nächsten erlaubten Bump aus den geladenen Daten.
const nowSec = () => Math.floor(Date.now() / 1000);
// lock sperrt den Button; die Restzeit steht jetzt oben in der Byline, auf
// Stunden aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
function lock(retryAfter) {
btn.disabled = true;
const hours = Math.max(1, Math.ceil(retryAfter / 3600));
if (cooldown) cooldown.textContent = ` · Repost in ${hours}h`;
}
const remaining = e.last_bump + e.bump_count * 86400 - nowSec();
if (e.bump_count > 0 && remaining > 0) lock(remaining);
btn.addEventListener("click", async () => {
const res = await api.post(`/entry/${e.pid}/bump`, new FormData());
if (res.ok) {
btn.textContent = `Repost (${res.data.bump_count})`;
lock(res.data.retry_after || 86400);
} else if (res.status === 429) {
lock(res.data.retry_after || 86400);
} else {
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen.
btn.textContent = res.data.error || "Fehler";
}
});
}
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor) {