feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото
Публиковать album.created/updated/deleted и изменения состава фото в личный поток SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые страницы альбома обновлялись без перезагрузки. - members.Repository получил Audience (владелец + участники) для дешёвого веера - albums.Service рассылает события в личные потоки получателей, перечитывая альбом через GetByID и attachCover, чтобы payload совпадал с GET /api/albums - members.Service публикует при принятии инвайта, смене роли и исключении - photos.Service публикует photo.created при загрузке и album.updated при добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении - photos_count теперь считает фото вне корзины - web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и применяет события к стору альбомов; страница альбома уходит при потере доступа
This commit is contained in:
@@ -15,6 +15,9 @@ import (
|
||||
|
||||
type MemberLookup interface {
|
||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
||||
// Audience resolves the users the album is visible to (owner + members) for
|
||||
// personal event fan-out.
|
||||
Audience(ctx context.Context, albumID string) ([]string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -28,6 +31,69 @@ func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage st
|
||||
return &Service{store: store, members: members, hub: hub, storage: storage}
|
||||
}
|
||||
|
||||
// Event names published to personal streams for the album lifecycle. They are
|
||||
// part of the client contract documented in project.md.
|
||||
const (
|
||||
EventAlbumCreated = "album.created"
|
||||
EventAlbumUpdated = "album.updated"
|
||||
EventAlbumDeleted = "album.deleted"
|
||||
)
|
||||
|
||||
// OwnerID returns the owner of the album, delegating to the store so callers
|
||||
// that only need the owner (members invites, role checks) can use this service
|
||||
// as their album reader.
|
||||
func (s *Service) OwnerID(ctx context.Context, albumID string) (string, error) {
|
||||
return s.store.OwnerID(ctx, albumID)
|
||||
}
|
||||
|
||||
// PublishAlbumTo fans an album lifecycle event out to the personal streams of
|
||||
// the given users. Events other than deletion carry the album re-read through
|
||||
// Store.GetByID and enriched with attachCover, so the payload has the same
|
||||
// shape as an element of GET /api/albums; deletion carries only {id} because
|
||||
// the album is already gone when it is published.
|
||||
func (s *Service) PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string) {
|
||||
if len(userIDs) == 0 || s.hub == nil {
|
||||
return
|
||||
}
|
||||
var payload any
|
||||
if eventType == EventAlbumDeleted {
|
||||
payload = map[string]string{"id": albumID}
|
||||
} else {
|
||||
a, err := s.store.GetByID(ctx, albumID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.attachCover(ctx, a)
|
||||
payload = a
|
||||
}
|
||||
for _, uid := range userIDs {
|
||||
s.hub.PublishUser(uid, eventType, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyAlbumChanged re-reads the album and publishes album.updated to the
|
||||
// personal streams of its owner and members, without touching the per-album
|
||||
// channel. Photo-composition changes use it: the open album page learns about
|
||||
// them from photo.* events, and a per-album album.updated would force a REST
|
||||
// reload for every photo.
|
||||
func (s *Service) NotifyAlbumChanged(ctx context.Context, albumID string) {
|
||||
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
|
||||
}
|
||||
|
||||
// albumAudience resolves the users an album is visible to. Resolution failures
|
||||
// are swallowed: a missed fan-out is recovered by the client's reconnect sync
|
||||
// and must not fail the mutation that triggered it.
|
||||
func (s *Service) albumAudience(ctx context.Context, albumID string) []string {
|
||||
if s.members == nil {
|
||||
return nil
|
||||
}
|
||||
ids, err := s.members.Audience(ctx, albumID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Title string
|
||||
Description string
|
||||
@@ -47,6 +113,7 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
|
||||
if err := s.store.Create(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PublishAlbumTo(ctx, a.ID, EventAlbumCreated, s.albumAudience(ctx, a.ID))
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -152,7 +219,11 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
|
||||
a = refreshed
|
||||
}
|
||||
s.attachCover(ctx, a)
|
||||
s.hub.Publish(albumID, "album.updated", a)
|
||||
s.hub.Publish(albumID, EventAlbumUpdated, a)
|
||||
// The per-album event above keeps the mobile client and the open album page
|
||||
// working; the personal fan-out below reaches everyone whose album list
|
||||
// changes, whether or not they are looking at this album.
|
||||
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -164,12 +235,16 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
|
||||
if role != roles.Owner {
|
||||
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
||||
}
|
||||
// The audience must be computed before deletion: album_members is removed by
|
||||
// cascade with the album, so the recipient list is unavailable afterwards.
|
||||
audience := s.albumAudience(ctx, a.ID)
|
||||
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Delete(ctx, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
|
||||
s.hub.Publish(albumID, EventAlbumDeleted, map[string]string{"id": a.ID})
|
||||
s.PublishAlbumTo(ctx, albumID, EventAlbumDeleted, audience)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package albums_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
assets "galo"
|
||||
"galo/internal/albums"
|
||||
"galo/internal/auth"
|
||||
"galo/internal/db"
|
||||
"galo/internal/models"
|
||||
"galo/internal/sse"
|
||||
"galo/internal/store"
|
||||
)
|
||||
|
||||
type stack struct {
|
||||
db *sql.DB
|
||||
repos store.Repos
|
||||
hub *sse.Hub
|
||||
svc *albums.Service
|
||||
}
|
||||
|
||||
// newStack opens a migrated SQLite database and wires the albums service over
|
||||
// the real repositories. Storage is nil: attachCover tolerates it and these
|
||||
// tests never inspect cover URLs.
|
||||
func newStack(t *testing.T) *stack {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "meta.db")
|
||||
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("migrations fs: %v", err)
|
||||
}
|
||||
if err := db.MigrateSQLite(path, migFS); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
database, err := db.OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
repos := store.NewSQLite(database)
|
||||
hub := sse.NewHub(16)
|
||||
return &stack{db: database, repos: repos, hub: hub, svc: albums.NewService(repos.Albums, repos.Members, hub, nil)}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, st *stack, name string) string {
|
||||
t.Helper()
|
||||
id := auth.NewID()
|
||||
if _, err := st.db.Exec(
|
||||
`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', $3)`,
|
||||
id, id[:8]+"@test.local", name); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func nextEvent(t *testing.T, sub *sse.Subscription) sse.Event {
|
||||
t.Helper()
|
||||
select {
|
||||
case ev, ok := <-sub.Events():
|
||||
if !ok {
|
||||
t.Fatal("subscription closed before an event arrived")
|
||||
}
|
||||
return ev
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for an event")
|
||||
}
|
||||
return sse.Event{}
|
||||
}
|
||||
|
||||
func assertNoEvent(t *testing.T, sub *sse.Subscription) {
|
||||
t.Helper()
|
||||
select {
|
||||
case ev, ok := <-sub.Events():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected event %q", ev.Type)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// jsonFields returns the sorted top-level JSON keys of v, used to assert that
|
||||
// an event payload exposes the same fields as a REST album element.
|
||||
func jsonFields(t *testing.T, v any) []string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func assertSameFields(t *testing.T, want, got any) {
|
||||
t.Helper()
|
||||
w := strings.Join(jsonFields(t, want), ",")
|
||||
g := strings.Join(jsonFields(t, got), ",")
|
||||
if w != g {
|
||||
t.Fatalf("payload fields differ:\n want %s\n got %s", w, g)
|
||||
}
|
||||
}
|
||||
|
||||
// Create must publish album.created to the owner, and the payload must be the
|
||||
// album re-read with its counters (Create itself returns the album without
|
||||
// them), matching the shape of a GET /api/albums element.
|
||||
func TestCreatePublishesAlbumCreatedToOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
owner := seedUser(t, st, "Owner")
|
||||
|
||||
sub := st.hub.SubscribeUser(owner)
|
||||
defer sub.Close()
|
||||
|
||||
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Trip", Description: "d"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
ev := nextEvent(t, sub)
|
||||
if ev.Type != albums.EventAlbumCreated {
|
||||
t.Fatalf("event type = %q, want %q", ev.Type, albums.EventAlbumCreated)
|
||||
}
|
||||
got, ok := ev.Data.(*models.Album)
|
||||
if !ok {
|
||||
t.Fatalf("payload type = %T, want *models.Album", ev.Data)
|
||||
}
|
||||
if got.ID != album.ID || got.Title != "Trip" || got.OwnerID != owner {
|
||||
t.Fatalf("payload album = %+v", got)
|
||||
}
|
||||
if got.PhotosCount != 0 || got.MembersCount != 0 {
|
||||
t.Fatalf("counters = photos:%d members:%d, want 0/0", got.PhotosCount, got.MembersCount)
|
||||
}
|
||||
|
||||
list, err := st.svc.List(ctx, owner)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
assertSameFields(t, &list[0], got)
|
||||
}
|
||||
|
||||
// A metadata change must reach the owner, every member and the per-album
|
||||
// channel, with a payload identical in shape to GET /api/albums/:id.
|
||||
func TestUpdatePublishesAlbumUpdatedToAudience(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
owner := seedUser(t, st, "Owner")
|
||||
member := seedUser(t, st, "Member")
|
||||
|
||||
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Old"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
|
||||
ownerSub := st.hub.SubscribeUser(owner)
|
||||
defer ownerSub.Close()
|
||||
memberSub := st.hub.SubscribeUser(member)
|
||||
defer memberSub.Close()
|
||||
albumSub := st.hub.SubscribeAlbum(album.ID)
|
||||
defer albumSub.Close()
|
||||
|
||||
title := "New"
|
||||
if _, err := st.svc.Update(ctx, album.ID, owner, albums.UpdateInput{Title: &title}); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
ownerEv := nextEvent(t, ownerSub)
|
||||
memberEv := nextEvent(t, memberSub)
|
||||
albumEv := nextEvent(t, albumSub)
|
||||
for name, ev := range map[string]sse.Event{"owner": ownerEv, "member": memberEv, "album": albumEv} {
|
||||
if ev.Type != albums.EventAlbumUpdated {
|
||||
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
got, ok := ownerEv.Data.(*models.Album)
|
||||
if !ok {
|
||||
t.Fatalf("owner payload type = %T", ownerEv.Data)
|
||||
}
|
||||
if got.Title != "New" || got.MembersCount != 1 {
|
||||
t.Fatalf("owner payload = %+v", got)
|
||||
}
|
||||
fetched, _, err := st.svc.Get(ctx, album.ID, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
assertSameFields(t, fetched, got)
|
||||
|
||||
memberAlbum, ok := memberEv.Data.(*models.Album)
|
||||
if !ok || memberAlbum.ID != album.ID || memberAlbum.Title != "New" {
|
||||
t.Fatalf("member payload = %#v", memberEv.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an album must notify the whole audience computed before the delete,
|
||||
// and the cascade must leave no members behind.
|
||||
func TestDeletePublishesAlbumDeletedToAudience(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
owner := seedUser(t, st, "Owner")
|
||||
member := seedUser(t, st, "Member")
|
||||
|
||||
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Doomed"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
|
||||
ownerSub := st.hub.SubscribeUser(owner)
|
||||
defer ownerSub.Close()
|
||||
memberSub := st.hub.SubscribeUser(member)
|
||||
defer memberSub.Close()
|
||||
|
||||
if err := st.svc.Delete(ctx, album.ID, owner); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
for name, sub := range map[string]*sse.Subscription{"owner": ownerSub, "member": memberSub} {
|
||||
ev := nextEvent(t, sub)
|
||||
if ev.Type != albums.EventAlbumDeleted {
|
||||
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumDeleted)
|
||||
}
|
||||
payload, ok := ev.Data.(map[string]string)
|
||||
if !ok || payload["id"] != album.ID {
|
||||
t.Fatalf("%s payload = %#v", name, ev.Data)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := st.repos.Members.List(ctx, album.ID)
|
||||
if err != nil || len(list) != 0 {
|
||||
t.Fatalf("members after delete: %v len=%d, want 0", err, len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Personal fan-out with no subscriber is safe and never reaches the per-album
|
||||
// channel, and an empty audience is a no-op.
|
||||
func TestPersonalPublishDoesNotReachAlbumChannel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
owner := seedUser(t, st, "Owner")
|
||||
|
||||
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Quiet"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
albumSub := st.hub.SubscribeAlbum(album.ID)
|
||||
defer albumSub.Close()
|
||||
|
||||
// Nobody subscribes to the owner's personal stream.
|
||||
st.svc.PublishAlbumTo(ctx, album.ID, albums.EventAlbumCreated, []string{owner})
|
||||
assertNoEvent(t, albumSub)
|
||||
|
||||
// An empty audience must not panic or read the album.
|
||||
st.svc.PublishAlbumTo(ctx, album.ID, albums.EventAlbumDeleted, nil)
|
||||
assertNoEvent(t, albumSub)
|
||||
}
|
||||
|
||||
// NotifyAlbumChanged reaches the recipients' personal streams and never the
|
||||
// per-album channel.
|
||||
func TestNotifyAlbumChangedReachesRecipientsOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
owner := seedUser(t, st, "Owner")
|
||||
member := seedUser(t, st, "Member")
|
||||
|
||||
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Notify"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
|
||||
ownerSub := st.hub.SubscribeUser(owner)
|
||||
defer ownerSub.Close()
|
||||
memberSub := st.hub.SubscribeUser(member)
|
||||
defer memberSub.Close()
|
||||
albumSub := st.hub.SubscribeAlbum(album.ID)
|
||||
defer albumSub.Close()
|
||||
|
||||
st.svc.NotifyAlbumChanged(ctx, album.ID)
|
||||
|
||||
for name, sub := range map[string]*sse.Subscription{"owner": ownerSub, "member": memberSub} {
|
||||
ev := nextEvent(t, sub)
|
||||
if ev.Type != albums.EventAlbumUpdated {
|
||||
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumUpdated)
|
||||
}
|
||||
if a, ok := ev.Data.(*models.Album); !ok || a.MembersCount != 1 {
|
||||
t.Fatalf("%s payload = %#v", name, ev.Data)
|
||||
}
|
||||
}
|
||||
assertNoEvent(t, albumSub)
|
||||
}
|
||||
|
||||
// photos_count counts only photos outside the trash.
|
||||
func TestPhotosCountExcludesTrash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newStack(t)
|
||||
owner := seedUser(t, st, "Owner")
|
||||
|
||||
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Count"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
insert := func(deleted bool) {
|
||||
t.Helper()
|
||||
var del any
|
||||
if deleted {
|
||||
del = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
if _, err := st.db.Exec(
|
||||
`INSERT INTO photos (id, album_id, uploader_id, deleted_at) VALUES ($1, $2, $3, $4)`,
|
||||
auth.NewID(), album.ID, owner, del); err != nil {
|
||||
t.Fatalf("insert photo: %v", err)
|
||||
}
|
||||
}
|
||||
insert(false)
|
||||
insert(false)
|
||||
insert(true)
|
||||
|
||||
got, err := st.repos.Albums.GetByID(ctx, album.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get album: %v", err)
|
||||
}
|
||||
if got.PhotosCount != 2 {
|
||||
t.Fatalf("photos_count = %d, want 2 (trashed photo excluded)", got.PhotosCount)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) {
|
||||
|
||||
const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
|
||||
a.created_at, a.updated_at, COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL),
|
||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
|
||||
|
||||
const sqliteAlbumJoins = `
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
|
||||
COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL),
|
||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||
FROM albums a
|
||||
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
|
||||
@@ -67,7 +67,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
|
||||
COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL),
|
||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||
FROM albums a
|
||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
||||
|
||||
Reference in New Issue
Block a user