Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13363cb568 | |||
| e4389de288 | |||
| aa0def2949 | |||
| ba0d0d62f2 | |||
| 32c70ad0f8 | |||
| 112e1f82ca | |||
| 308eec734f | |||
| 1cfbb8b8fe | |||
| 01be0c523e | |||
| e965775809 |
@@ -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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user