Nizak prioritet iz pregleda koda: indeksi, greške, sitne popravke
- migracija 102: indeksi na FK kolonama (stavke_prodaje, servisni_delovi,
servisni_potrazivani_delovi, magacinske_promene, stavke_nabavke, pdv_kir,
pdv_kpr) — potvrđeno kreiranje i idempotentnost
- BrojKriticnihZaliha/KriticneZalihe filtriraju samo proizvode i aktivne
artikle, usklađeno sa ArtikalRepo.Lista(SamoKriticni)
- url.QueryEscape na query string u magacin.go/klijent.go (paginacija)
- TestFiskalizacije/BeStatus vraćaju generičku poruku, detalj u slog
- SetFlash loguje grešku umesto tihog ignorisanja
- os.WriteFile/os.MkdirAll u main.go proveravaju grešku
- dummyHash generisanje panikuje na startu ako bcrypt ikad zakaže
(umesto tihog sloma anti-enumeracije)
- PrihvatiPredlozene proverava redovi.Err()
- /healthz endpoint (DB ping)
- hx-boost na "Osveži" linkovima (blokirane_ip, fiskal_pazar)
- MaxBytesReader (4KB) na javne /status/{token}/* POST rute
- komentar u auth.go zašto postoje dva flash mehanizma (namerno, ne spojeno)
Provereno: jePrivatnaAdresa ne radi DNS lookup (nema net.LookupIP u repou),
pa DNS-rebinding nalaz iz pregleda nije primenljiv na trenutni kod.
This commit is contained in:
+28
-5
@@ -58,7 +58,9 @@ func main() {
|
|||||||
// kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard
|
// kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard
|
||||||
if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" {
|
if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" {
|
||||||
if _, err := os.Stat(envFajl); os.IsNotExist(err) {
|
if _, err := os.Stat(envFajl); os.IsNotExist(err) {
|
||||||
os.WriteFile(envFajl, []byte(""), 0600)
|
if err := os.WriteFile(envFajl, []byte(""), 0600); err != nil {
|
||||||
|
slog.Error("kreiranje praznog ntech.env nije uspelo", "putanja", envFajl, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
godotenv.Load(envFajl)
|
godotenv.Load(envFajl)
|
||||||
@@ -141,7 +143,9 @@ func main() {
|
|||||||
napraviBackup(db, putanjaBaze, max)
|
napraviBackup(db, putanjaBaze, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
os.MkdirAll("web/static/uploads", 0755)
|
if err := os.MkdirAll("web/static/uploads", 0755); err != nil {
|
||||||
|
slog.Error("kreiranje foldera za uploade nije uspelo", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
h := handler.Novi(db, totpKljuc)
|
h := handler.Novi(db, totpKljuc)
|
||||||
h.Verzija = Verzija
|
h.Verzija = Verzija
|
||||||
@@ -238,6 +242,16 @@ func main() {
|
|||||||
http.FileServer(http.FS(staticFS)).ServeHTTP(w, req)
|
http.FileServer(http.FS(staticFS)).ServeHTTP(w, req)
|
||||||
})))
|
})))
|
||||||
|
|
||||||
|
// health check — bez autentifikacije, za monitoring/orkestraciju (Docker healthcheck i sl.)
|
||||||
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := db.PingContext(r.Context()); err != nil {
|
||||||
|
http.Error(w, "baza nedostupna", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
// javne rute (bez autentifikacije), ali i dalje sa CSRF zaštitom — sprečava login-CSRF
|
// javne rute (bez autentifikacije), ali i dalje sa CSRF zaštitom — sprečava login-CSRF
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(ntechmw.CsrfMiddleware)
|
r.Use(ntechmw.CsrfMiddleware)
|
||||||
@@ -254,9 +268,18 @@ func main() {
|
|||||||
// primenjuje isti pretpostavljeni napadački model (napadač bez tokena ne može
|
// primenjuje isti pretpostavljeni napadački model (napadač bez tokena ne može
|
||||||
// ni da pogodi rutu).
|
// ni da pogodi rutu).
|
||||||
r.Get("/status/{token}", h.ServisJavniStatus)
|
r.Get("/status/{token}", h.ServisJavniStatus)
|
||||||
r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati)
|
r.Group(func(r chi.Router) {
|
||||||
r.Post("/status/{token}/odbij", h.ServisJavniOdbij)
|
// telo javnih POST-ova (komentar klijenta) ograničeno na 4KB — nema razloga da bude veće
|
||||||
r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano)
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati)
|
||||||
|
r.Post("/status/{token}/odbij", h.ServisJavniOdbij)
|
||||||
|
r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano)
|
||||||
|
})
|
||||||
r.Get("/v/", h.FiskalVerifikacija)
|
r.Get("/v/", h.FiskalVerifikacija)
|
||||||
|
|
||||||
// zaštićene rute — zahtevaju prijavljenog korisnika
|
// zaštićene rute — zahtevaju prijavljenog korisnika
|
||||||
|
|||||||
+12
-1
@@ -29,7 +29,18 @@ func ProveriLozinku(hash, lozinka string) bool {
|
|||||||
|
|
||||||
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
|
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
|
||||||
// Koristi ga IzjednaciVremeProvere kada korisnik ne postoji.
|
// Koristi ga IzjednaciVremeProvere kada korisnik ne postoji.
|
||||||
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost)
|
var dummyHash []byte
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
h, err := bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
// cost je fiksna konstanta i lozinka nije prazna — ovo se praktično ne
|
||||||
|
// može desiti; ako se ipak desi, tiho propadanje bi obesmislilo
|
||||||
|
// anti-enumeraciju u IzjednaciVremeProvere, pa je bolje pući na startu.
|
||||||
|
panic(fmt.Sprintf("ntech: auth: generisanje dummyHash nije uspelo: %v", err))
|
||||||
|
}
|
||||||
|
dummyHash = h
|
||||||
|
}
|
||||||
|
|
||||||
// IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme
|
// IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme
|
||||||
// odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom —
|
// odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom —
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er
|
|||||||
func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) {
|
func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.db.QueryRowContext(ctx,
|
||||||
`SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min`).Scan(&n)
|
`SELECT COUNT(*) FROM artikli WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0`).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err)
|
return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err)
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func (r *sqliteIzvestajRepo) PoslednjiServisi(ctx context.Context, limit int) ([
|
|||||||
func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) {
|
func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT naziv, kolicina, kolicina_min FROM artikli
|
SELECT naziv, kolicina, kolicina_min FROM artikli
|
||||||
WHERE kolicina <= kolicina_min
|
WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0
|
||||||
ORDER BY kolicina ASC LIMIT ?`, limit)
|
ORDER BY kolicina ASC LIMIT ?`, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err)
|
return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err)
|
||||||
|
|||||||
@@ -259,6 +259,9 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int
|
|||||||
}
|
}
|
||||||
stavke = append(stavke, s)
|
stavke = append(stavke, s)
|
||||||
}
|
}
|
||||||
|
if err := redovi.Err(); err != nil {
|
||||||
|
return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može)
|
// Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može)
|
||||||
for _, s := range stavke {
|
for _, s := range stavke {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -82,10 +83,10 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
queryDelići := ""
|
queryDelići := ""
|
||||||
if pretraga != "" {
|
if pretraga != "" {
|
||||||
queryDelići += "&pretraga=" + pretraga
|
queryDelići += "&pretraga=" + url.QueryEscape(pretraga)
|
||||||
}
|
}
|
||||||
if tipFilter != "" {
|
if tipFilter != "" {
|
||||||
queryDelići += "&tip=" + tipFilter
|
queryDelići += "&tip=" + url.QueryEscape(tipFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
stranicaPrev := stranicaBr - 1
|
stranicaPrev := stranicaBr - 1
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"ntech/internal/db"
|
"ntech/internal/db"
|
||||||
@@ -104,10 +105,10 @@ func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, str
|
|||||||
// izgradi query string za paginaciju (čuva filtere)
|
// izgradi query string za paginaciju (čuva filtere)
|
||||||
queryDelići := ""
|
queryDelići := ""
|
||||||
if v := filter.Pretraga; v != "" {
|
if v := filter.Pretraga; v != "" {
|
||||||
queryDelići += "&pretraga=" + v
|
queryDelići += "&pretraga=" + url.QueryEscape(v)
|
||||||
}
|
}
|
||||||
if katIDStr != "" {
|
if katIDStr != "" {
|
||||||
queryDelići += "&kategorija=" + katIDStr
|
queryDelići += "&kategorija=" + url.QueryEscape(katIDStr)
|
||||||
}
|
}
|
||||||
if filter.SamoKriticni {
|
if filter.SamoKriticni {
|
||||||
queryDelići += "&kriticni=1"
|
queryDelići += "&kriticni=1"
|
||||||
|
|||||||
@@ -999,16 +999,18 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
|
|||||||
klijent := &http.Client{Timeout: 5 * time.Second}
|
klijent := &http.Client{Timeout: 5 * time.Second}
|
||||||
resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese)
|
resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("test fiskalizacije: konekcija nije uspela", "url", statusURL, "error", err)
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
fmt.Fprintf(w, `<div class="fisk-status greska">✗ Nije dostupan — %s</div>`, html.EscapeString(err.Error()))
|
fmt.Fprint(w, `<div class="fisk-status greska">✗ Nije dostupan — proverite adresu i mrežu</div>`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var status map[string]any
|
var status map[string]any
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
||||||
|
slog.Error("test fiskalizacije: neispravan odgovor servera", "url", statusURL, "error", err)
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
fmt.Fprintf(w, `<div class="fisk-status greska">✗ Neispravan odgovor servera — %s</div>`, html.EscapeString(err.Error()))
|
fmt.Fprint(w, `<div class="fisk-status greska">✗ Neispravan odgovor servera</div>`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1041,9 +1043,10 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("BeStatus: emulator nedostupan", "addr", addr, "error", err)
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
fmt.Fprintf(w, `<div class="fisk-status greska">✗ Emulator nije dostupan na %s — %s</div>`,
|
fmt.Fprintf(w, `<div class="fisk-status greska">✗ Emulator nije dostupan na %s</div>`,
|
||||||
html.EscapeString(addr), html.EscapeString(err.Error()))
|
html.EscapeString(addr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|||||||
@@ -149,7 +149,12 @@ func RequireDozvolaMut(proveri func(ctx context.Context, uloga, akcija string) b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić
|
// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić. Namerno
|
||||||
|
// odvojen mehanizam od SetFlash/GetFlash (flash.go): ove funkcije se pozivaju
|
||||||
|
// iz middleware-a koji nema pristup *sql.DB, a redirect je uvek na /dashboard
|
||||||
|
// (jedino mesto koje čita ovaj kolačić) — pa kolačić bez DB upisa dovoljan.
|
||||||
|
// SetFlash/GetFlash (kolona sesije.flash) koristi se u handlerima koji imaju
|
||||||
|
// DB i redirect na proizvoljnu stranicu.
|
||||||
func postaviFlashGresku(w http.ResponseWriter, poruka string) {
|
func postaviFlashGresku(w http.ResponseWriter, poruka string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "ntech_flash_greska",
|
Name: "ntech_flash_greska",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
@@ -18,9 +19,11 @@ func SetFlash(w http.ResponseWriter, r *http.Request, db *sql.DB, tip, poruka st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.ExecContext(r.Context(),
|
if _, err := db.ExecContext(r.Context(),
|
||||||
`UPDATE sesije SET flash = ? WHERE token = ?`,
|
`UPDATE sesije SET flash = ? WHERE token = ?`,
|
||||||
string(data), kolacic.Value)
|
string(data), kolacic.Value); err != nil {
|
||||||
|
slog.Error("SetFlash: upis nije uspeo", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFlash čita i atomično briše flash poruku iz aktivne sesije
|
// GetFlash čita i atomično briše flash poruku iz aktivne sesije
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Indeksi na FK kolonama koje se često JOIN-uju/filtriraju (detalji naloga, izveštaji).
|
||||||
|
-- Bez indeksa ove tabele rade full table scan koji raste sa prometom.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_nalog_id ON stavke_prodaje(nalog_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_artikal_id ON stavke_prodaje(artikal_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servisni_delovi_nalog_id ON servisni_delovi(nalog_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servisni_delovi_artikal_id ON servisni_delovi(artikal_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_nalog_id ON servisni_potrazivani_delovi(nalog_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_artikal_id ON servisni_potrazivani_delovi(artikal_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_magacinske_promene_artikal_id ON magacinske_promene(artikal_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stavke_nabavke_nabavka_id ON stavke_nabavke(nabavka_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pdv_kir_izvor ON pdv_kir(izvor, izvor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pdv_kir_broj_dokumenta ON pdv_kir(broj_dokumenta);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pdv_kpr_izvor ON pdv_kpr(izvor, izvor_id);
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="kartica animiraj" style="padding: 0; overflow: hidden">
|
<div class="kartica animiraj" style="padding: 0; overflow: hidden">
|
||||||
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
|
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
|
||||||
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
|
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
|
||||||
<a href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
|
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Lista}}
|
{{if .Lista}}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
<a href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
|
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
|
||||||
|
|
||||||
{{if index .Dozvole "fiskal.zakljucenje"}}
|
{{if index .Dozvole "fiskal.zakljucenje"}}
|
||||||
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload
|
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload
|
||||||
|
|||||||
Reference in New Issue
Block a user