mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
add middleware logging to xprc requests in hold. add tangled profile creation
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -92,6 +93,9 @@ func main() {
|
||||
// Setup HTTP routes with chi router
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Add logging middleware to log all HTTP requests
|
||||
r.Use(middleware.Logger)
|
||||
|
||||
// Root page
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
|
||||
@@ -612,3 +612,424 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
|
||||
return nil
|
||||
}
|
||||
func (t *TangledProfileRecord) MarshalCBOR(w io.Writer) error {
|
||||
if t == nil {
|
||||
_, err := w.Write(cbg.CborNull)
|
||||
return err
|
||||
}
|
||||
|
||||
cw := cbg.NewCborWriter(w)
|
||||
|
||||
if _, err := cw.Write([]byte{167}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Type (string) (string)
|
||||
if len("$type") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"$type\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("$type")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Type) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.Type was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.Type)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Links ([]string) (slice)
|
||||
if len("links") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"links\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("links")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Links) > 8192 {
|
||||
return xerrors.Errorf("Slice value in field t.Links was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range t.Links {
|
||||
if len(v) > 8192 {
|
||||
return xerrors.Errorf("Value in field v was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// t.Stats ([]string) (slice)
|
||||
if len("stats") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"stats\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("stats")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Stats) > 8192 {
|
||||
return xerrors.Errorf("Slice value in field t.Stats was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range t.Stats {
|
||||
if len(v) > 8192 {
|
||||
return xerrors.Errorf("Value in field v was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// t.Bluesky (bool) (bool)
|
||||
if len("bluesky") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"bluesky\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("bluesky")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cbg.WriteBool(w, t.Bluesky); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Location (string) (string)
|
||||
if len("location") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"location\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("location")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Location) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.Location was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Location))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.Location)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Description (string) (string)
|
||||
if len("description") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"description\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("description")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Description) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.Description was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.Description)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.PinnedRepositories ([]string) (slice)
|
||||
if len("pinnedRepositories") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("pinnedRepositories")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.PinnedRepositories) > 8192 {
|
||||
return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range t.PinnedRepositories {
|
||||
if len(v) > 8192 {
|
||||
return xerrors.Errorf("Value in field v was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TangledProfileRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
*t = TangledProfileRecord{}
|
||||
|
||||
cr := cbg.NewCborReader(r)
|
||||
|
||||
maj, extra, err := cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}()
|
||||
|
||||
if maj != cbg.MajMap {
|
||||
return fmt.Errorf("cbor input should be of type map")
|
||||
}
|
||||
|
||||
if extra > cbg.MaxLength {
|
||||
return fmt.Errorf("TangledProfileRecord: map struct too large (%d)", extra)
|
||||
}
|
||||
|
||||
n := extra
|
||||
|
||||
nameBuf := make([]byte, 18)
|
||||
for i := uint64(0); i < n; i++ {
|
||||
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
// Field doesn't exist on this type, so ignore it
|
||||
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch string(nameBuf[:nameLen]) {
|
||||
// t.Type (string) (string)
|
||||
case "$type":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Type = string(sval)
|
||||
}
|
||||
// t.Links ([]string) (slice)
|
||||
case "links":
|
||||
|
||||
maj, extra, err = cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if extra > 8192 {
|
||||
return fmt.Errorf("t.Links: array too large (%d)", extra)
|
||||
}
|
||||
|
||||
if maj != cbg.MajArray {
|
||||
return fmt.Errorf("expected cbor array")
|
||||
}
|
||||
|
||||
if extra > 0 {
|
||||
t.Links = make([]string, extra)
|
||||
}
|
||||
|
||||
for i := 0; i < int(extra); i++ {
|
||||
{
|
||||
var maj byte
|
||||
var extra uint64
|
||||
var err error
|
||||
_ = maj
|
||||
_ = extra
|
||||
_ = err
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Links[i] = string(sval)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// t.Stats ([]string) (slice)
|
||||
case "stats":
|
||||
|
||||
maj, extra, err = cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if extra > 8192 {
|
||||
return fmt.Errorf("t.Stats: array too large (%d)", extra)
|
||||
}
|
||||
|
||||
if maj != cbg.MajArray {
|
||||
return fmt.Errorf("expected cbor array")
|
||||
}
|
||||
|
||||
if extra > 0 {
|
||||
t.Stats = make([]string, extra)
|
||||
}
|
||||
|
||||
for i := 0; i < int(extra); i++ {
|
||||
{
|
||||
var maj byte
|
||||
var extra uint64
|
||||
var err error
|
||||
_ = maj
|
||||
_ = extra
|
||||
_ = err
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Stats[i] = string(sval)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// t.Bluesky (bool) (bool)
|
||||
case "bluesky":
|
||||
|
||||
maj, extra, err = cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if maj != cbg.MajOther {
|
||||
return fmt.Errorf("booleans must be major type 7")
|
||||
}
|
||||
switch extra {
|
||||
case 20:
|
||||
t.Bluesky = false
|
||||
case 21:
|
||||
t.Bluesky = true
|
||||
default:
|
||||
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
|
||||
}
|
||||
// t.Location (string) (string)
|
||||
case "location":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Location = string(sval)
|
||||
}
|
||||
// t.Description (string) (string)
|
||||
case "description":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Description = string(sval)
|
||||
}
|
||||
// t.PinnedRepositories ([]string) (slice)
|
||||
case "pinnedRepositories":
|
||||
|
||||
maj, extra, err = cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if extra > 8192 {
|
||||
return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra)
|
||||
}
|
||||
|
||||
if maj != cbg.MajArray {
|
||||
return fmt.Errorf("expected cbor array")
|
||||
}
|
||||
|
||||
if extra > 0 {
|
||||
t.PinnedRepositories = make([]string, extra)
|
||||
}
|
||||
|
||||
for i := 0; i < int(extra); i++ {
|
||||
{
|
||||
var maj byte
|
||||
var extra uint64
|
||||
var err error
|
||||
_ = maj
|
||||
_ = extra
|
||||
_ = err
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.PinnedRepositories[i] = string(sval)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Field doesn't exist on this type, so ignore it
|
||||
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -25,10 +25,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate map-style encoders for CrewRecord and CaptainRecord
|
||||
// Generate map-style encoders for CrewRecord, CaptainRecord, and TangledProfileRecord
|
||||
if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
|
||||
atproto.CrewRecord{},
|
||||
atproto.CaptainRecord{},
|
||||
atproto.TangledProfileRecord{},
|
||||
); err != nil {
|
||||
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -34,6 +34,10 @@ const (
|
||||
// Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS)
|
||||
CrewCollection = "io.atcr.hold.crew"
|
||||
|
||||
// TangledProfileCollection is the collection name for tangled profiles
|
||||
// Stored in hold's embedded PDS (singleton record at rkey "self")
|
||||
TangledProfileCollection = "sh.tangled.actor.profile"
|
||||
|
||||
// SailorProfileCollection is the collection name for user profiles
|
||||
SailorProfileCollection = "io.atcr.sailor.profile"
|
||||
|
||||
@@ -515,3 +519,17 @@ type CrewRecord struct {
|
||||
Permissions []string `json:"permissions" cborgen:"permissions"`
|
||||
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
|
||||
}
|
||||
|
||||
// TangledProfileRecord represents a Tangled profile for the hold
|
||||
// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
|
||||
// Stored in the hold's embedded PDS
|
||||
// Uses CBOR encoding for efficient storage in hold's carstore
|
||||
type TangledProfileRecord struct {
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
Links []string `json:"links" cborgen:"links"`
|
||||
Stats []string `json:"stats" cborgen:"stats"`
|
||||
Bluesky bool `json:"bluesky" cborgen:"bluesky"`
|
||||
Location string `json:"location" cborgen:"location"`
|
||||
Description string `json:"description" cborgen:"description"`
|
||||
PinnedRepositories []string `json:"pinnedRepositories" cborgen:"pinnedRepositories"`
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
bsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
@@ -22,6 +23,12 @@ const (
|
||||
|
||||
// ProfileCollection is the collection name for Bluesky actor profiles
|
||||
ProfileCollection = "app.bsky.actor.profile"
|
||||
|
||||
// TangledProfileRkey is the fixed rkey for the tangled profile record (singleton)
|
||||
TangledProfileRkey = "self"
|
||||
|
||||
// TangledProfileCollection is the collection name for Tangled actor profiles
|
||||
TangledProfileCollection = "sh.tangled.actor.profile"
|
||||
)
|
||||
|
||||
// downloadImage downloads an image from a URL and returns the data and content type
|
||||
@@ -172,3 +179,44 @@ func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorPro
|
||||
|
||||
return recordCID, profileRecord, nil
|
||||
}
|
||||
|
||||
// CreateTangledProfileRecord creates the sh.tangled.actor.profile record for the hold
|
||||
// This will FAIL if the tangled profile record already exists.
|
||||
func (p *HoldPDS) CreateTangledProfileRecord(ctx context.Context, links []string, description string) (cid.Cid, error) {
|
||||
// Create tangled profile struct
|
||||
profile := &atproto.TangledProfileRecord{
|
||||
Type: atproto.TangledProfileCollection,
|
||||
Links: links,
|
||||
Stats: []string{}, // Empty for now
|
||||
Bluesky: true,
|
||||
Location: "",
|
||||
Description: description,
|
||||
PinnedRepositories: []string{}, // Empty for now
|
||||
}
|
||||
|
||||
// Use repomgr.PutRecord - creates with explicit rkey, fails if already exists
|
||||
recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, TangledProfileCollection, TangledProfileRkey, profile)
|
||||
if err != nil {
|
||||
return cid.Undef, fmt.Errorf("failed to create tangled profile record: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created tangled profile record at %s, cid: %s\n", recordPath, recordCID)
|
||||
return recordCID, nil
|
||||
}
|
||||
|
||||
// GetTangledProfileRecord retrieves the sh.tangled.actor.profile record
|
||||
func (p *HoldPDS) GetTangledProfileRecord(ctx context.Context) (cid.Cid, *atproto.TangledProfileRecord, error) {
|
||||
// Use repomgr.GetRecord
|
||||
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, TangledProfileCollection, TangledProfileRkey, cid.Undef)
|
||||
if err != nil {
|
||||
return cid.Undef, nil, fmt.Errorf("failed to get tangled profile record: %w", err)
|
||||
}
|
||||
|
||||
// Type assert to TangledProfileRecord
|
||||
profileRecord, ok := val.(*atproto.TangledProfileRecord)
|
||||
if !ok {
|
||||
return cid.Undef, nil, fmt.Errorf("unexpected type for tangled profile record: %T", val)
|
||||
}
|
||||
|
||||
return recordCID, profileRecord, nil
|
||||
}
|
||||
|
||||
+23
-6
@@ -20,10 +20,11 @@ import (
|
||||
// init registers our custom ATProto types with indigo's lexutil type registry
|
||||
// This allows repomgr.GetRecord to automatically unmarshal our types
|
||||
func init() {
|
||||
// Register captain and crew record types
|
||||
// Register captain, crew, and tangled profile record types
|
||||
// These must match the $type field in the records
|
||||
lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{})
|
||||
lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{})
|
||||
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
|
||||
}
|
||||
|
||||
// HoldPDS is a minimal ATProto PDS implementation for a hold service
|
||||
@@ -157,23 +158,39 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
|
||||
fmt.Printf("✅ Added %s as hold admin\n", ownerDID)
|
||||
}
|
||||
|
||||
// Create profile record (idempotent - check if exists first)
|
||||
// Create Bluesky profile record (idempotent - check if exists first)
|
||||
// This runs even if captain exists (for existing holds being upgraded)
|
||||
// Skip if no storage driver (e.g., in tests)
|
||||
if storageDriver != nil {
|
||||
_, _, err = p.GetProfileRecord(ctx)
|
||||
if err != nil {
|
||||
// Profile doesn't exist, create it
|
||||
// Bluesky profile doesn't exist, create it
|
||||
displayName := "Cargo Hold"
|
||||
description := "ahoy from the cargo hold"
|
||||
|
||||
_, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create profile record: %w", err)
|
||||
return fmt.Errorf("failed to create bluesky profile record: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ Created profile record (displayName=%s)\n", displayName)
|
||||
fmt.Printf("✅ Created Bluesky profile record (displayName=%s)\n", displayName)
|
||||
} else {
|
||||
fmt.Printf("✅ Profile record already exists, skipping\n")
|
||||
fmt.Printf("✅ Bluesky profile record already exists, skipping\n")
|
||||
}
|
||||
|
||||
// Create Tangled profile record (idempotent - check if exists first)
|
||||
_, _, err = p.GetTangledProfileRecord(ctx)
|
||||
if err != nil {
|
||||
// Tangled profile doesn't exist, create it
|
||||
description := "ahoy from the cargo hold"
|
||||
links := []string{"https://atcr.io"}
|
||||
|
||||
_, err = p.CreateTangledProfileRecord(ctx, links, description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tangled profile record: %w", err)
|
||||
}
|
||||
fmt.Printf("✅ Created Tangled profile record\n")
|
||||
} else {
|
||||
fmt.Printf("✅ Tangled profile record already exists, skipping\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user