mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
Give `amy git` the git-object read side of `nak git download` / a shallow clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at a ref, and `log` shows recent commit history — all over the git smart-HTTP v2 protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref` override the URL and branch/tag. Read-only: pushing git objects back to clone/GRASP servers stays out of scope. Verified live against a public repo (octocat/Hello-World) — browse/cat/log all return correct trees, blobs, and history. The harness gains a `--live` block (28 assertions with `--live`, 24 in the default offline run) exercising these against `$LIVE_REPO`, skipped by default since it needs a reachable git host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
This commit is contained in:
@@ -329,6 +329,18 @@ the default is the repo's advertised relays, else your outbox.
|
||||
| `amy git grasp list [USER]` | List a user's kind:10317 GRASP hosting-server list (defaults to self). |
|
||||
| `amy git grasp set URL[,URL]` | Publish your GRASP hosting-server list (preference order — where PR tips get pushed). |
|
||||
|
||||
**Read repository content** (git smart-HTTP v2, read-only — needs a reachable git host)
|
||||
|
||||
`REPO` here is a repo coordinate/naddr (whose announcement supplies the clone
|
||||
URL) **or** a raw `http(s)` clone URL. This is the git-object read side of
|
||||
`nak git download` / a shallow clone; pushing objects back is out of scope.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy git browse REPO [PATH] [--ref R] [--clone URL]` | List a repo's tree entries at PATH (default: root). |
|
||||
| `amy git cat REPO PATH [--ref R] [--out FILE]` | Print a file's contents at a ref (or write raw bytes to `--out`). |
|
||||
| `amy git log REPO [--ref R] [--depth N] [--clone URL]` | Recent commit history, most recent first. |
|
||||
|
||||
**Issues, patches & pull requests**
|
||||
|
||||
| Command | What it does |
|
||||
|
||||
+3
-2
@@ -109,7 +109,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
|
||||
| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. |
|
||||
| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. |
|
||||
| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). |
|
||||
| `git` | `amy git` | ✅ (events) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. |
|
||||
| `git` | `amy git` | ✅ (events + read) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) is out of scope. |
|
||||
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
|
||||
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
|
||||
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
|
||||
@@ -130,7 +130,8 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage:
|
||||
`admin`) are interop-verified against the real `nak` binary or `amy serve`.
|
||||
- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`),
|
||||
`git` (full NIP-34 event surface — announce/state/patch/PR/issue/comment/status
|
||||
+ status-deriving reads — but no git-packfile clone/push transport), `outbox`
|
||||
+ status-deriving reads + GRASP list, plus `browse`/`cat`/`log` reading git
|
||||
objects over smart-HTTP; only packfile **push** is out of scope), `outbox`
|
||||
(NIP-65 model vs nak's local hints DB).
|
||||
- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
|
||||
`spell` (MuSig2/FROST), and `validate` (event-schema validation).
|
||||
|
||||
@@ -633,6 +633,8 @@ private fun printUsage() {
|
||||
| git list [USER] list a user's repo announcements (default self)
|
||||
| git show NADDR|kind:pubkey:id print one repo announcement
|
||||
| git grasp list [USER] | set URL[,URL] GRASP hosting-server list (kind 10317)
|
||||
| git browse REPO [PATH] | cat REPO PATH read repo tree/file over git smart-HTTP
|
||||
| git log REPO [--depth N] recent commit history (read-only)
|
||||
| git issue REPO --subject S [BODY] publish a kind:1621 issue against a repo
|
||||
| [--hashtag T[,T]] (BODY from arg or stdin)
|
||||
| git patch REPO [--file P] [--root] publish a kind:1617 patch (format-patch/stdin)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `amy git browse|cat|log` — read a repository's actual git content over the
|
||||
* git smart-HTTP v2 protocol (the same [GitHttpClient] the Android repo browser
|
||||
* uses). Resolves the http(s) clone URL from the kind:30617 announcement and
|
||||
* does a shallow fetch, so this is read-only and needs a reachable git host.
|
||||
* This is the git-object read side of `nak git download` / a shallow `clone`;
|
||||
* pushing objects back is still out of scope (see cli/ROADMAP.md).
|
||||
*/
|
||||
object GitBrowseCommands {
|
||||
/** `git browse REPO [PATH]` — list the tree entries at PATH (default: repo root). */
|
||||
suspend fun browse(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val coord = args.positional(0, "repo-naddr-or-coordinates")
|
||||
val path = args.positionalOrNull(1).orEmpty()
|
||||
val ref = args.flag("ref")
|
||||
val cloneOverride = args.flag("clone")
|
||||
args.rejectUnknown("relay")
|
||||
|
||||
return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot ->
|
||||
val segments = splitPath(path)
|
||||
val entries =
|
||||
if (segments.isEmpty()) {
|
||||
snapshot.rootEntries()
|
||||
} else {
|
||||
snapshot.entriesAt(segments)
|
||||
?: return@withSnapshot Output.error("not_found", "no directory at path '$path'")
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"clone_url" to snapshot.cloneUrl,
|
||||
"branch" to snapshot.branch,
|
||||
"head_commit" to snapshot.headCommit,
|
||||
"path" to path,
|
||||
"count" to entries.size,
|
||||
"entries" to entries.map(::entrySummary),
|
||||
),
|
||||
)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/** `git cat REPO PATH` — print (or `--out FILE`) a file's contents at a ref. */
|
||||
suspend fun cat(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val coord = args.positional(0, "repo-naddr-or-coordinates")
|
||||
val path = args.positional(1, "file-path")
|
||||
val ref = args.flag("ref")
|
||||
val cloneOverride = args.flag("clone")
|
||||
val out = args.flag("out")
|
||||
args.rejectUnknown("relay")
|
||||
|
||||
return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot ->
|
||||
val entry =
|
||||
snapshot.entryAt(splitPath(path))
|
||||
?: return@withSnapshot Output.error("not_found", "no file at path '$path'")
|
||||
if (entry.isFolder) return@withSnapshot Output.error("bad_args", "'$path' is a directory (use `git browse`)")
|
||||
val bytes = snapshot.readBlob(entry.oid)
|
||||
val binary = isBinary(bytes)
|
||||
if (out != null) {
|
||||
File(out).writeBytes(bytes)
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"clone_url" to snapshot.cloneUrl,
|
||||
"path" to path,
|
||||
"oid" to entry.oid,
|
||||
"size_bytes" to bytes.size,
|
||||
"binary" to binary,
|
||||
"written_to" to out,
|
||||
// Text content is inlined only when it isn't binary and wasn't written to a file.
|
||||
"content" to if (!binary && out == null) bytes.decodeToString() else null,
|
||||
),
|
||||
)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/** `git log REPO` — recent commit history (most recent first). */
|
||||
suspend fun log(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val coord = args.positional(0, "repo-naddr-or-coordinates")
|
||||
val ref = args.flag("ref")
|
||||
val cloneOverride = args.flag("clone")
|
||||
val depth = args.intFlag("depth", 50)
|
||||
args.rejectUnknown("relay")
|
||||
|
||||
return withSnapshot(dataDir, coord, ref, cloneOverride, args) { http, snapshot ->
|
||||
val commits = http.loadHistory(snapshot.cloneUrl, snapshot.headCommit, depth)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"clone_url" to snapshot.cloneUrl,
|
||||
"branch" to snapshot.branch,
|
||||
"count" to commits.size,
|
||||
"commits" to
|
||||
commits.map {
|
||||
mapOf(
|
||||
"oid" to it.oid,
|
||||
"short_oid" to it.shortOid,
|
||||
"summary" to it.summary,
|
||||
"author" to it.authorName,
|
||||
"author_email" to it.authorEmail,
|
||||
"author_time" to it.authorTimeSec,
|
||||
"parents" to it.parents,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the repo announcement, pick its http(s) clone URLs, open a shallow
|
||||
* snapshot (trying each candidate URL), and hand the client + snapshot to
|
||||
* [block]. Emits a `not_found` / `unreachable` error when the repo or a
|
||||
* working clone URL can't be resolved.
|
||||
*/
|
||||
private suspend fun withSnapshot(
|
||||
dataDir: DataDir,
|
||||
coord: String,
|
||||
ref: String?,
|
||||
cloneOverride: String?,
|
||||
args: Args,
|
||||
block: suspend (GitHttpClient, GitRepoSnapshot) -> Int,
|
||||
): Int {
|
||||
// REPO may be a raw http(s) clone URL, an naddr, or `kind:pubkey:id`.
|
||||
val directUrl = coord.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
||||
val addr =
|
||||
if (directUrl == null) {
|
||||
GitSupport.resolveAddress(coord)
|
||||
?: return Output.error("bad_args", "expected a clone URL, an naddr, or kind:pubkey:identifier")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val cloneUrls =
|
||||
when {
|
||||
cloneOverride != null -> listOf(cloneOverride)
|
||||
directUrl != null -> listOf(directUrl)
|
||||
else -> {
|
||||
val repo =
|
||||
GitSupport.fetchRepo(ctx, addr!!, args)
|
||||
?: return Output.error("not_found", "no repository announcement found for $coord")
|
||||
repo.clones()
|
||||
}
|
||||
}
|
||||
val candidates = candidateUrls(cloneUrls)
|
||||
if (candidates.isEmpty()) {
|
||||
return Output.error("bad_args", "repository has no http(s) clone URL (pass --clone URL)")
|
||||
}
|
||||
val http = GitHttpClient { ctx.okhttp }
|
||||
val errors = StringBuilder()
|
||||
for (url in candidates) {
|
||||
val snapshot =
|
||||
try {
|
||||
http.open(url, ref)
|
||||
} catch (e: Exception) {
|
||||
errors
|
||||
.append(url)
|
||||
.append(" → ")
|
||||
.append(e.message ?: e.toString())
|
||||
.append('\n')
|
||||
null
|
||||
}
|
||||
if (snapshot != null) return block(http, snapshot)
|
||||
}
|
||||
return Output.error("unreachable", "could not clone any candidate URL:\n${errors.toString().trim()}")
|
||||
}
|
||||
}
|
||||
|
||||
/** http(s) clone URLs to try, in order, adding a `.git` variant when missing. */
|
||||
private fun candidateUrls(cloneUrls: List<String>): List<String> {
|
||||
val out = LinkedHashSet<String>()
|
||||
for (raw in cloneUrls) {
|
||||
val url = raw.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) continue
|
||||
out.add(url)
|
||||
if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git")
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
|
||||
private fun splitPath(path: String): List<String> =
|
||||
path
|
||||
.trim()
|
||||
.trim('/')
|
||||
.split('/')
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
private fun entrySummary(entry: GitTreeEntry): Map<String, Any?> =
|
||||
mapOf(
|
||||
"name" to entry.name,
|
||||
"type" to
|
||||
when {
|
||||
entry.isFolder -> "dir"
|
||||
entry.isSubmodule -> "submodule"
|
||||
entry.isSymlink -> "symlink"
|
||||
else -> "file"
|
||||
},
|
||||
"oid" to entry.oid,
|
||||
)
|
||||
|
||||
/** A blob is treated as binary when it contains a NUL byte in its head. */
|
||||
private fun isBinary(bytes: ByteArray): Boolean = bytes.take(8000).any { it.toInt() == 0 }
|
||||
}
|
||||
@@ -61,6 +61,11 @@ object GitCommands {
|
||||
| git show NADDR|kind:pubkey:id print one repo announcement
|
||||
| git grasp list [USER] | set URL[,URL] a user's GRASP hosting-server list (kind 10317)
|
||||
|
|
||||
|Read repo content (git smart-HTTP, read-only — needs a reachable git host):
|
||||
| git browse REPO [PATH] [--ref R] [--clone URL] list a repo's tree at PATH (default root)
|
||||
| git cat REPO PATH [--ref R] [--out FILE] print (or write) a file's contents at a ref
|
||||
| git log REPO [--ref R] [--depth N] recent commit history (most recent first)
|
||||
|
|
||||
|Issues / patches / pull requests:
|
||||
| git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin)
|
||||
| [--hashtag T[,T]]
|
||||
@@ -90,13 +95,16 @@ object GitCommands {
|
||||
route(
|
||||
"git",
|
||||
tail,
|
||||
"git <announce|state|list|show|grasp|issue|patch|pr|comment|open|applied|close|draft|issues|patches|prs|thread>",
|
||||
"git <announce|state|list|show|grasp|browse|cat|log|issue|patch|pr|comment|open|applied|close|draft|issues|patches|prs|thread>",
|
||||
mapOf(
|
||||
"announce" to { rest -> announce(dataDir, rest) },
|
||||
"state" to { rest -> state(dataDir, rest) },
|
||||
"list" to { rest -> list(dataDir, rest) },
|
||||
"show" to { rest -> show(dataDir, rest) },
|
||||
"grasp" to { rest -> GitGraspCommands.dispatch(dataDir, rest) },
|
||||
"browse" to { rest -> GitBrowseCommands.browse(dataDir, rest) },
|
||||
"cat" to { rest -> GitBrowseCommands.cat(dataDir, rest) },
|
||||
"log" to { rest -> GitBrowseCommands.log(dataDir, rest) },
|
||||
"issue" to { rest -> issue(dataDir, rest) },
|
||||
"issues" to { rest -> GitReadCommands.issues(dataDir, rest) },
|
||||
"patch" to { rest -> GitPatchCommands.patch(dataDir, rest) },
|
||||
|
||||
+9
-5
@@ -68,11 +68,15 @@ Suite notes:
|
||||
both directions (plus the `--no-sync-deletions` opt-out) against
|
||||
`amy serve`, with one `$HOME` per account so stores don't share.
|
||||
- **`git/git-nip34-headless.sh`** drives the full NIP-34 collaboration surface
|
||||
against `amy serve`: announce (30617) + state (30618), issue (1621), patch
|
||||
(1617), pull request (1618) + update (1619), NIP-22 comment (1111), and
|
||||
status events (1630-1633), then asserts the `issues`/`patches`/`prs`/`thread`
|
||||
reads derive the right status (a closed issue reads `closed`, an applied PR
|
||||
reads `applied`) and that `--open`/`--closed` filter correctly.
|
||||
against `amy serve`: announce (30617) + state (30618) + GRASP list (10317),
|
||||
issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22
|
||||
comment (1111), and status events (1630-1633), then asserts the
|
||||
`issues`/`patches`/`prs`/`thread` reads derive the right status (a closed
|
||||
issue reads `closed`, an applied PR reads `applied`) and that
|
||||
`--open`/`--closed` filter correctly. Pass `--live` to additionally exercise
|
||||
the git smart-HTTP reads (`git browse`/`cat`/`log`) against a real public
|
||||
repo (`$LIVE_REPO`, default octocat/Hello-World) — skipped by default since
|
||||
it needs a reachable git host.
|
||||
|
||||
The Marmot harnesses come in two flavours, same scenarios:
|
||||
|
||||
|
||||
@@ -32,11 +32,14 @@ RELAY_HOST="127.0.0.1"
|
||||
RELAY_PORT="${RELAY_PORT:-7793}"
|
||||
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
|
||||
NO_BUILD=0
|
||||
LIVE=0
|
||||
LIVE_REPO="${LIVE_REPO:-https://github.com/octocat/Hello-World.git}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
--live) LIVE=1 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
@@ -204,5 +207,25 @@ assert_eq "$(echo "$THREAD" | jq -r '.status')" "closed" thread.status "thread s
|
||||
assert_eq "$(echo "$THREAD" | jq -r '.status_events | length')" "1" thread.status_events "one status event in timeline"
|
||||
assert_eq "$(echo "$THREAD" | jq -r '.comments | length')" "1" thread.comments "one comment in thread"
|
||||
|
||||
# =============================================================================
|
||||
# Read repo content over git smart-HTTP (--live only — needs a reachable host).
|
||||
# =============================================================================
|
||||
if [[ "$LIVE" -eq 1 ]]; then
|
||||
banner "live: git browse / cat / log ($LIVE_REPO)"
|
||||
BROWSE="$(M git browse "$LIVE_REPO" --json)"
|
||||
info "browse: $BROWSE"
|
||||
assert_nonempty "$(echo "$BROWSE" | jq -r '.head_commit')" live.browse "browse resolves a head commit"
|
||||
FIRST_FILE="$(echo "$BROWSE" | jq -r '.entries[] | select(.type=="file") | .name' | head -1)"
|
||||
if [[ -n "$FIRST_FILE" ]]; then
|
||||
CAT="$(M git cat "$LIVE_REPO" "$FIRST_FILE" --json)"
|
||||
assert_eq "$(echo "$CAT" | jq -r '.path')" "$FIRST_FILE" live.cat "cat returns the requested file"
|
||||
assert_nonempty "$(echo "$CAT" | jq -r '.oid')" live.cat_oid "cat resolves a blob oid"
|
||||
fi
|
||||
LOG="$(M git log "$LIVE_REPO" --depth 2 --json)"
|
||||
assert_nonempty "$(echo "$LOG" | jq -r '.commits[0].oid')" live.log "log returns at least one commit"
|
||||
else
|
||||
skip_msg "live git smart-HTTP reads (browse/cat/log) — pass --live to run"
|
||||
fi
|
||||
|
||||
grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user