mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Fixed fatal startup crash caused by an unloadable plugin
This commit is contained in:
1 parent
115c330be3
commit
74f9ee92ad
9 files changed
+286
-13
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/BishopFox/joro/internal/browser"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/plugins"
|
||||
"github.com/BishopFox/joro/internal/update"
|
||||
)
|
||||
|
||||
@@ -135,6 +136,14 @@ func (s *APIServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The new binary will not load plugins the old one built. Say so while the
|
||||
// banner is still on screen; after the restart it is a row in Settings.
|
||||
if s.pluginManager != nil {
|
||||
if notice := plugins.RebuildNotice(s.pluginManager.PluginDir()); notice != "" {
|
||||
progress(notice)
|
||||
}
|
||||
}
|
||||
|
||||
s.hub.Broadcast() <- event.WSEvent{
|
||||
Type: "system.update.restarting",
|
||||
Data: map[string]string{},
|
||||
|
||||
@@ -64,6 +64,14 @@ type Config struct {
|
||||
// engagement where nothing may leave the machine — a webhook is the one feature whose
|
||||
// bytes go somewhere Joro's scope does not describe.
|
||||
NoWebhooks bool
|
||||
// NoPlugins skips loading plugins from ~/.joro/plugins/ — the files are still
|
||||
// listed, so the UI can delete one, but no plugin code runs.
|
||||
//
|
||||
// This is a recovery switch, not only a posture one. A Go plugin is dlopen'd
|
||||
// before the API server starts, and the runtime can throw on a file it will not
|
||||
// accept, so a plugin the host cannot load is a binary that will not boot. Every
|
||||
// other route out of that state needs a shell in ~/.joro/plugins/.
|
||||
NoPlugins bool
|
||||
}
|
||||
|
||||
// Default returns a Config populated with sensible defaults.
|
||||
|
||||
+159
-1
@@ -2,18 +2,27 @@ package plugins
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"debug/buildinfo"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/sdk"
|
||||
)
|
||||
|
||||
// sdkModule is the SDK import path, whose version must agree between host and
|
||||
// plugin. Named here rather than inlined because verifyPluginObject looks it up
|
||||
// on both sides.
|
||||
const sdkModule = "github.com/BishopFox/joro/sdk"
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// reservedNames may not be used as plugin names because they conflict
|
||||
@@ -76,7 +85,11 @@ func loadPlugins(dir string) ([]loadedPlugin, []loadFailure, []error) {
|
||||
}
|
||||
lp.filename = name
|
||||
|
||||
manifest := lp.ext.Manifest()
|
||||
manifest, err := safeManifest(lp.ext)
|
||||
if err != nil {
|
||||
failed = append(failed, loadFailure{filename: name, err: err})
|
||||
continue
|
||||
}
|
||||
if err := validateManifest(manifest); err != nil {
|
||||
failed = append(failed, loadFailure{filename: name, err: err})
|
||||
continue
|
||||
@@ -96,12 +109,109 @@ func loadPlugins(dir string) ([]loadedPlugin, []loadFailure, []error) {
|
||||
return loaded, failed, nil
|
||||
}
|
||||
|
||||
// verifyPluginObject reports why path is not a plugin this binary can load,
|
||||
// and must be called before plugin.Open.
|
||||
//
|
||||
// plugin.Open does not merely return an error on a file it cannot use: for some
|
||||
// files Go's runtime calls throw(), which no recover() can catch and which takes
|
||||
// the whole process down. plugin_lastmoduleinit walks the module chain
|
||||
//
|
||||
// for pmd := firstmoduledata.next; pmd != nil; pmd = pmd.next {
|
||||
// if pmd.bad { md = nil; continue }
|
||||
// md = pmd
|
||||
// }
|
||||
// if md == nil { throw("runtime: no plugin module data") }
|
||||
//
|
||||
// so any dlopen that appends no usable Go module is fatal — a file that is not a
|
||||
// Go object, one built with a buildmode other than plugin, a truncated download,
|
||||
// or a chain whose tail an earlier rejected load already marked bad. The plugin
|
||||
// dir is operator-writable and survives an in-app update that replaces the
|
||||
// binary underneath it, so a file the host can no longer load is the expected
|
||||
// steady state, not an exotic one. Joro loads plugins before the API server
|
||||
// starts, so letting one through means a binary that cannot boot at all and an
|
||||
// operator with no way to reach the UI and delete it.
|
||||
//
|
||||
// Everything here is therefore a gate rather than a warning, and rejecting a
|
||||
// plugin that would in fact have loaded costs a rebuild, where accepting one
|
||||
// that would not costs the process. Dropping any of these checks re-opens an
|
||||
// unrecoverable crash.
|
||||
func verifyPluginObject(path string) error {
|
||||
bi, err := buildinfo.ReadFile(path)
|
||||
if err != nil {
|
||||
// ReadFile's own wrapper repeats the full path, which the caller already
|
||||
// reports as a filename and which does not fit a one-line banner.
|
||||
if inner := errors.Unwrap(err); inner != nil {
|
||||
err = inner
|
||||
}
|
||||
return fmt.Errorf("not a Go plugin (no Go build information): %w", err)
|
||||
}
|
||||
|
||||
var buildmode string
|
||||
for _, s := range bi.Settings {
|
||||
if s.Key == "-buildmode" {
|
||||
buildmode = s.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
// cmd/go records -buildmode unconditionally, so a real plugin always carries
|
||||
// it and an empty value means the file was not stamped by `go build` at all.
|
||||
if buildmode != "plugin" {
|
||||
if buildmode == "" {
|
||||
return fmt.Errorf("not built as a Go plugin (no -buildmode recorded); rebuild with `joro --build-plugin <dir> --install`")
|
||||
}
|
||||
return fmt.Errorf("built with -buildmode=%s, want plugin; rebuild with `joro --build-plugin <dir> --install`", buildmode)
|
||||
}
|
||||
|
||||
// Go's own check is a per-package content hash, which an exact version match
|
||||
// implies. Comparing version strings instead reports the mismatch in the terms
|
||||
// the operator can act on, and does so before dlopen rather than after.
|
||||
if bi.GoVersion != runtime.Version() {
|
||||
return fmt.Errorf("built with %s, this joro binary is %s; rebuild with `joro --build-plugin <dir> --install`", bi.GoVersion, runtime.Version())
|
||||
}
|
||||
|
||||
if hostSDK, ok := hostSDKVersion(); ok {
|
||||
if pluginSDK, ok := moduleVersion(bi.Deps, sdkModule); ok && pluginSDK != hostSDK {
|
||||
return fmt.Errorf("built against %s %s, this joro binary embeds %s; rebuild with `joro --build-plugin <dir> --install`", sdkModule, pluginSDK, hostSDK)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostSDKVersion returns the SDK version this binary was built against.
|
||||
func hostSDKVersion() (string, bool) {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return moduleVersion(info.Deps, sdkModule)
|
||||
}
|
||||
|
||||
// moduleVersion finds a module's effective version in a dependency list,
|
||||
// following a replace directive to the module that actually got built.
|
||||
func moduleVersion(deps []*debug.Module, path string) (string, bool) {
|
||||
for _, d := range deps {
|
||||
if d == nil || d.Path != path {
|
||||
continue
|
||||
}
|
||||
if d.Replace != nil {
|
||||
return d.Replace.Version, true
|
||||
}
|
||||
return d.Version, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func loadOne(path string) (loadedPlugin, error) {
|
||||
hash, err := fileHash(path)
|
||||
if err != nil {
|
||||
return loadedPlugin{}, fmt.Errorf("hash: %w", err)
|
||||
}
|
||||
|
||||
if err := verifyPluginObject(path); err != nil {
|
||||
return loadedPlugin{}, err
|
||||
}
|
||||
|
||||
p, err := plugin.Open(path)
|
||||
if err != nil {
|
||||
// "plugin: not implemented" surfaces when the host binary was built
|
||||
@@ -131,6 +241,54 @@ func isPluginFile(name string) bool {
|
||||
return strings.HasSuffix(name, ".so") || strings.HasSuffix(name, ".dylib")
|
||||
}
|
||||
|
||||
// safeManifest calls a plugin's Manifest with panic recovery. It is the first
|
||||
// call into plugin code on the load path, and it runs before there is a name to
|
||||
// report the plugin by, so an unguarded panic here takes down startup with a
|
||||
// stack trace naming no file.
|
||||
func safeManifest(ext sdk.Plugin) (m sdk.Manifest, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic in Manifest: %v", r)
|
||||
}
|
||||
}()
|
||||
return ext.Manifest(), nil
|
||||
}
|
||||
|
||||
// installedPlugins returns the plugin filenames in dir, ignoring everything
|
||||
// else. A missing dir is not an error: it means no plugins are installed.
|
||||
func installedPlugins(dir string) []string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && isPluginFile(entry.Name()) {
|
||||
out = append(out, entry.Name())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RebuildNotice returns a one-line warning for an operator who has just updated
|
||||
// Joro, or "" when no plugins are installed.
|
||||
//
|
||||
// A plugin is only loadable by the exact toolchain that built the host, so
|
||||
// replacing the binary invalidates every installed .so — silently, since the
|
||||
// plugin dir is untouched by an update. Sharing isPluginFile with the loader is
|
||||
// what keeps the notice and the thing it warns about counting the same files.
|
||||
func RebuildNotice(dir string) string {
|
||||
n := len(installedPlugins(dir))
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
noun := "plugin"
|
||||
if n > 1 {
|
||||
noun = "plugins"
|
||||
}
|
||||
return fmt.Sprintf("Note: %d installed %s must be rebuilt against this version before it will load again: `joro --build-plugin <dir> --install`", n, noun)
|
||||
}
|
||||
|
||||
func validateManifest(m sdk.Manifest) error {
|
||||
if !nameRe.MatchString(m.Name) {
|
||||
return fmt.Errorf("invalid name %q: must match %s", m.Name, nameRe.String())
|
||||
|
||||
@@ -18,7 +18,7 @@ type PluginInfo struct {
|
||||
Version string `json:"version"`
|
||||
Desc string `json:"description"`
|
||||
Type sdk.PluginType `json:"type"`
|
||||
Status string `json:"status"` // "loaded", "error"
|
||||
Status string `json:"status"` // "loaded", "error", "removed", "disabled"
|
||||
Error string `json:"error,omitempty"`
|
||||
Hash string `json:"hash"` // SHA-256 of .so file
|
||||
Filename string `json:"filename"` // original .so/.dylib filename
|
||||
@@ -191,6 +191,25 @@ func (m *Manager) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartDisabled is Start with the loading left out, for --no-plugins.
|
||||
//
|
||||
// It still lists what is installed, as rows no plugin code was run to produce.
|
||||
// Booting with plugins off is how an operator recovers from a plugin that
|
||||
// prevents booting at all, so the list is the point: an empty one would hide the
|
||||
// very file the flag exists to delete, and the delete endpoint works from a row.
|
||||
func (m *Manager) StartDisabled() error {
|
||||
for _, name := range installedPlugins(m.pluginDir) {
|
||||
m.allPlugins = append(m.allPlugins, PluginInfo{
|
||||
Status: "disabled",
|
||||
Filename: name,
|
||||
})
|
||||
}
|
||||
if len(m.allPlugins) > 0 {
|
||||
log.Printf("[plugins] --no-plugins: %d installed plugin(s) not loaded", len(m.allPlugins))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown calls Shutdown() on each loaded plugin with panic recovery.
|
||||
func (m *Manager) Shutdown() {
|
||||
m.mu.RLock()
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
"github.com/BishopFox/joro/internal/xsshunter"
|
||||
)
|
||||
|
||||
var version = "v1.15.0"
|
||||
var version = "v1.15.1"
|
||||
var commit = "dev" // injected via -ldflags at build time
|
||||
|
||||
func main() {
|
||||
@@ -66,6 +66,7 @@ func main() {
|
||||
flag.BoolVar(&cfg.AutomationScripting, "automation-scripting", false, "Expose script.run, which executes submitted JavaScript in a sandboxed worker process against Joro's automation SDK (off by default; still requires an explicit grant)")
|
||||
flag.BoolVar(&cfg.AutomationCommands, "automation-commands", false, "Allow installed command automations to run local operating-system commands, on a trigger or as a lens (off by default; operator-installed only, never reachable from an automation token)")
|
||||
flag.BoolVar(&cfg.NoWebhooks, "no-webhooks", false, "Disable outbound webhooks entirely (no routes, no webhooks.json, no outbound requests)")
|
||||
flag.BoolVar(&cfg.NoPlugins, "no-plugins", false, "Do not load plugins from ~/.joro/plugins/. They are still listed in Settings so one can be deleted — use this to start after a plugin the binary cannot load")
|
||||
|
||||
// scriptWorker is how a script sandbox re-execs this binary. Not an operator
|
||||
// switch: it reads a job from stdin and speaks a private protocol on stdout.
|
||||
@@ -353,6 +354,9 @@ func runProxyMode(ctx context.Context, cfg config.Config) {
|
||||
if err := update.RunUpdate(func(msg string) { fmt.Println(msg) }); err != nil {
|
||||
log.Printf("Update failed: %v", err)
|
||||
} else {
|
||||
if notice := plugins.RebuildNotice(filepath.Join(cfg.DataDir, "plugins")); notice != "" {
|
||||
fmt.Println(notice)
|
||||
}
|
||||
fmt.Println("Restarting...")
|
||||
if err := update.Restart(); err != nil {
|
||||
log.Fatalf("Restart failed: %v", err)
|
||||
@@ -399,10 +403,18 @@ func runProxyMode(ctx context.Context, cfg config.Config) {
|
||||
hub := api.NewHub()
|
||||
go hub.Run()
|
||||
|
||||
// Load plugins from ~/.joro/plugins/.
|
||||
// Load plugins from ~/.joro/plugins/. Under --no-plugins they are listed but
|
||||
// never opened, which is the only way back from a plugin that stops the binary
|
||||
// booting at all.
|
||||
pluginMgr := plugins.NewManager(filepath.Join(cfg.DataDir, "plugins"), hub.Broadcast())
|
||||
if err := pluginMgr.Start(ctx); err != nil {
|
||||
log.Printf("plugin manager: %v", err)
|
||||
var pluginErr error
|
||||
if cfg.NoPlugins {
|
||||
pluginErr = pluginMgr.StartDisabled()
|
||||
} else {
|
||||
pluginErr = pluginMgr.Start(ctx)
|
||||
}
|
||||
if pluginErr != nil {
|
||||
log.Printf("plugin manager: %v", pluginErr)
|
||||
}
|
||||
defer pluginMgr.Shutdown()
|
||||
|
||||
@@ -551,13 +563,16 @@ func runBuildPlugin(srcDir, output string, install bool, dataDir string) int {
|
||||
}
|
||||
|
||||
// Go's plugin loader rejects .so files built with a different Go toolchain
|
||||
// version than the host binary. Warn loudly when `go build` in srcDir
|
||||
// would use a different toolchain than this binary was compiled with.
|
||||
// version than the host binary, so building one is producing an artifact that
|
||||
// cannot load — and an unloadable plugin left in ~/.joro/plugins/ is what
|
||||
// stops the host booting. Refuse rather than warn: the file not existing is
|
||||
// strictly better than the file existing and being installed.
|
||||
// Run `go version` from srcDir so the plugin's go.mod (and any
|
||||
// auto-toolchain directive) influences which version is reported.
|
||||
if v, ok := localGoVersion(srcDir); ok && v != runtime.Version() {
|
||||
fmt.Fprintf(os.Stderr, " Warning: local Go toolchain (%s) differs from this binary's Go version (%s).\n", v, runtime.Version())
|
||||
fmt.Fprintf(os.Stderr, " The built plugin may fail to load. Match versions or rebuild joro from source.\n")
|
||||
fmt.Fprintf(os.Stderr, "error: local Go toolchain (%s) differs from this binary's Go version (%s).\n", v, runtime.Version())
|
||||
fmt.Fprintf(os.Stderr, " The built plugin would not load. Match versions or rebuild joro from source.\n")
|
||||
return 1
|
||||
}
|
||||
|
||||
// Run go build -buildmode=plugin, forwarding the host's ABI-relevant
|
||||
|
||||
+7
-1
@@ -2,11 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import Toasts from './components/Toasts'
|
||||
import UpdateBanner from './components/UpdateBanner'
|
||||
import PluginBanner from './components/PluginBanner'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { Navigate, NavLink, Route, Routes } from 'react-router'
|
||||
import ContextMenu from './components/ContextMenu'
|
||||
import { getSelectionMenuItems } from './lib/selectionMenu'
|
||||
import { api } from './lib/api'
|
||||
import { api, type PluginInfo } from './lib/api'
|
||||
import { connectWS } from './lib/ws'
|
||||
import { Settings, isTeamMode, useSettingsStore } from './stores/settingsStore'
|
||||
import { useTeamConnectionStore, type RelayState } from './stores/teamConnectionStore'
|
||||
@@ -67,6 +68,7 @@ export default function App() {
|
||||
const [needsAuth, setNeedsAuth] = useState(false)
|
||||
const [pluginTabs, setPluginTabs] = useState<Array<{ to: string; label: string }>>([])
|
||||
const [dashboardPlugin, setDashboardPlugin] = useState<string | null>(null)
|
||||
const [failedPlugins, setFailedPlugins] = useState<PluginInfo[]>([])
|
||||
const hiddenTabs = useHiddenTabsStore((s) => s.hiddenTabs)
|
||||
const stagedCount = useDeadDropStore((s) => s.staged.length)
|
||||
const activeProject = useProjectStore((s) => s.active)
|
||||
@@ -127,6 +129,9 @@ export default function App() {
|
||||
)
|
||||
const dash = plugs.find((e) => e.type === 'dashboard' && e.status === 'loaded')
|
||||
if (dash) setDashboardPlugin(dash.name)
|
||||
// A plugin that would not load has no tab to show, so the banner is the
|
||||
// only place an operator who never opens Settings would hear about it.
|
||||
setFailedPlugins(plugs.filter((e) => e.status === 'error'))
|
||||
}).catch(() => {})
|
||||
}, [checkTeamMode])
|
||||
|
||||
@@ -209,6 +214,7 @@ export default function App() {
|
||||
<div className="flex flex-col h-screen">
|
||||
<Toasts />
|
||||
<UpdateBanner />
|
||||
<PluginBanner failed={failedPlugins} />
|
||||
{/* Top nav */}
|
||||
<header className="flex items-center gap-0.5 px-2 lg:px-3 h-10 bg-surface-card border-b border-border shrink-0 overflow-x-auto">
|
||||
<span className="text-accent text-sm font-bold uppercase tracking-wider mr-3 lg:mr-6 shrink-0">Joro</span>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import type { PluginInfo } from '../lib/api'
|
||||
|
||||
// PluginBanner reports plugins that did not load. The plugin list is owned by
|
||||
// App (which needs it for the tab nav anyway), so this takes the failed rows as
|
||||
// a prop rather than fetching a third copy of it.
|
||||
//
|
||||
// One banner, never one per plugin: a host binary that has moved past the
|
||||
// toolchain its plugins were built against fails all of them at once, and the
|
||||
// operator's next step is the same page either way. Only the single-failure case
|
||||
// is worth naming a file for; past that the count is the useful part and the
|
||||
// Settings table has the rest.
|
||||
export default function PluginBanner({ failed }: { failed: PluginInfo[] }) {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
if (failed.length === 0 || dismissed) return null
|
||||
|
||||
const only = failed.length === 1 ? failed[0] : null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-surface-input border-b border-border text-xs text-content-secondary">
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<AlertTriangle size={12} className="text-semantic-warning shrink-0" />
|
||||
<span className="truncate">
|
||||
{only ? (
|
||||
<>
|
||||
Plugin <code className="text-semantic-warning">{only.filename}</code> failed to load
|
||||
{only.error ? `: ${only.error}` : '.'}
|
||||
</>
|
||||
) : (
|
||||
<>{failed.length} plugins failed to load.</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2 ml-4 shrink-0">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="text-accent-secondary hover:text-accent-secondary-hover underline"
|
||||
>
|
||||
Open plugin settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="text-content-muted hover:text-content-primary text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -225,6 +225,10 @@ function ManagePanel({ plugins, onRefresh }: { plugins: PluginInfo[]; onRefresh:
|
||||
<Tooltip content="File deleted. The plugin keeps running until you restart.">
|
||||
<span className="text-semantic-warning font-semibold">Removed</span>
|
||||
</Tooltip>
|
||||
) : p.status === 'disabled' ? (
|
||||
<Tooltip content="Joro was started with --no-plugins, so this file was listed but never loaded.">
|
||||
<span className="text-content-muted font-semibold">Disabled</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content={p.error || 'Error'}>
|
||||
<span className="text-semantic-error font-semibold">
|
||||
|
||||
+3
-2
@@ -928,8 +928,9 @@ export interface PluginInfo {
|
||||
type: string // "exec_provider" | "tab" | "feature" | "proxy_hook" | "dashboard"
|
||||
// "removed" means the file is deleted but the code is still loaded, which lasts
|
||||
// until a restart. A row with status "error" and an empty name is a file that
|
||||
// would not load at all, so it has only a filename and a reason.
|
||||
status: string // "loaded" | "error" | "removed"
|
||||
// would not load at all, so it has only a filename and a reason. "disabled" is
|
||||
// the same shape, for a file --no-plugins listed without opening.
|
||||
status: string // "loaded" | "error" | "removed" | "disabled"
|
||||
error?: string
|
||||
hash: string
|
||||
filename: string
|
||||
|
||||
Reference in new issue
Block a user