feat: initial commit
This commit is contained in:
59
internal/command/client/cast.go
Normal file
59
internal/command/client/cast.go
Normal file
@ -0,0 +1,59 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
|
||||
"forge.cadoles.com/arcad/arcast/pkg/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func Cast() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "cast",
|
||||
ArgsUsage: "<url>",
|
||||
Flags: flag.ComposeFlags(
|
||||
playerFlags...,
|
||||
),
|
||||
Action: func(ctx *cli.Context) error {
|
||||
baseFlags := flag.GetBaseFlags(ctx)
|
||||
|
||||
url := ctx.Args().First()
|
||||
if url == "" {
|
||||
return errors.New("you must specify an url to cast")
|
||||
}
|
||||
|
||||
statuses := make([]wrappedStatus, 0)
|
||||
|
||||
err := forEachPlayer(ctx, func(cl *client.Client, playerAddr string) error {
|
||||
status, err := cl.Cast(ctx.Context, playerAddr, url)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not cast")
|
||||
}
|
||||
|
||||
statuses = append(statuses, wrappedStatus{
|
||||
Status: status.Status,
|
||||
URL: status.URL,
|
||||
Title: status.Title,
|
||||
ID: status.ID,
|
||||
Address: playerAddr,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
hints := wrappedStatusHints(baseFlags.OutputMode)
|
||||
|
||||
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(statuses...)...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
92
internal/command/client/flag/flag.go
Normal file
92
internal/command/client/flag/flag.go
Normal file
@ -0,0 +1,92 @@
|
||||
package flag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
_ "gitlab.com/wpetit/goweb/cli/format/json"
|
||||
"gitlab.com/wpetit/goweb/cli/format/table"
|
||||
)
|
||||
|
||||
func ComposeFlags(flags ...cli.Flag) []cli.Flag {
|
||||
baseFlags := []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "format",
|
||||
Aliases: []string{"f"},
|
||||
Usage: fmt.Sprintf("use `FORMAT` as output format (available: %s)", format.Available()),
|
||||
Value: string(table.Format),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mode",
|
||||
Aliases: []string{"m"},
|
||||
Usage: fmt.Sprintf("use `MODE` as output mode (available: %s)", []format.OutputMode{format.OutputModeCompact, format.OutputModeWide}),
|
||||
Value: string(format.OutputModeCompact),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"t"},
|
||||
EnvVars: []string{`ARCAST_TOKEN`},
|
||||
Usage: "use `TOKEN` as authentication token",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token-file",
|
||||
EnvVars: []string{`ARCAST_TOKEN_FILE`},
|
||||
Usage: "use `TOKEN_FILE` as file containing the authentication token",
|
||||
Value: ".bouncer-token",
|
||||
TakesFile: true,
|
||||
},
|
||||
}
|
||||
|
||||
flags = append(flags, baseFlags...)
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
type BaseFlags struct {
|
||||
ServerURL string
|
||||
Format format.Format
|
||||
OutputMode format.OutputMode
|
||||
Token string
|
||||
TokenFile string
|
||||
}
|
||||
|
||||
func GetBaseFlags(ctx *cli.Context) *BaseFlags {
|
||||
serverURL := ctx.String("server")
|
||||
rawFormat := ctx.String("format")
|
||||
rawOutputMode := ctx.String("mode")
|
||||
tokenFile := ctx.String("token-file")
|
||||
token := ctx.String("token")
|
||||
|
||||
return &BaseFlags{
|
||||
ServerURL: serverURL,
|
||||
Format: format.Format(rawFormat),
|
||||
OutputMode: format.OutputMode(rawOutputMode),
|
||||
Token: token,
|
||||
TokenFile: tokenFile,
|
||||
}
|
||||
}
|
||||
|
||||
func GetToken(flags *BaseFlags) (string, error) {
|
||||
if flags.Token != "" {
|
||||
return flags.Token, nil
|
||||
}
|
||||
|
||||
if flags.TokenFile == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
rawToken, err := os.ReadFile(flags.TokenFile)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
if rawToken == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(rawToken)), nil
|
||||
}
|
37
internal/command/client/hint.go
Normal file
37
internal/command/client/hint.go
Normal file
@ -0,0 +1,37 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func playerHints(outputMode format.OutputMode) format.Hints {
|
||||
return format.Hints{
|
||||
OutputMode: outputMode,
|
||||
Props: []format.Prop{
|
||||
format.NewProp("ID", "ID"),
|
||||
format.NewProp("IPs", "IPs"),
|
||||
format.NewProp("Port", "Port"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type wrappedStatus struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
func wrappedStatusHints(outputMode format.OutputMode) format.Hints {
|
||||
return format.Hints{
|
||||
OutputMode: outputMode,
|
||||
Props: []format.Prop{
|
||||
format.NewProp("ID", "ID"),
|
||||
format.NewProp("Address", "Address"),
|
||||
format.NewProp("Status", "Status"),
|
||||
format.NewProp("Title", "Title"),
|
||||
format.NewProp("URL", "URL"),
|
||||
},
|
||||
}
|
||||
}
|
53
internal/command/client/reset.go
Normal file
53
internal/command/client/reset.go
Normal file
@ -0,0 +1,53 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
|
||||
"forge.cadoles.com/arcad/arcast/pkg/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func Reset() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reset",
|
||||
Flags: flag.ComposeFlags(
|
||||
playerFlags...,
|
||||
),
|
||||
Action: func(ctx *cli.Context) error {
|
||||
baseFlags := flag.GetBaseFlags(ctx)
|
||||
|
||||
statuses := make([]wrappedStatus, 0)
|
||||
|
||||
err := forEachPlayer(ctx, func(cl *client.Client, playerAddr string) error {
|
||||
status, err := cl.Reset(ctx.Context, playerAddr)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not cast")
|
||||
}
|
||||
|
||||
statuses = append(statuses, wrappedStatus{
|
||||
Status: status.Status,
|
||||
URL: status.URL,
|
||||
Title: status.Title,
|
||||
ID: status.ID,
|
||||
Address: playerAddr,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
hints := wrappedStatusHints(baseFlags.OutputMode)
|
||||
|
||||
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(statuses...)...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
15
internal/command/client/root.go
Normal file
15
internal/command/client/root.go
Normal file
@ -0,0 +1,15 @@
|
||||
package client
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "client",
|
||||
Subcommands: []*cli.Command{
|
||||
Scan(),
|
||||
Cast(),
|
||||
Reset(),
|
||||
Status(),
|
||||
},
|
||||
}
|
||||
}
|
49
internal/command/client/scan.go
Normal file
49
internal/command/client/scan.go
Normal file
@ -0,0 +1,49 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
|
||||
"forge.cadoles.com/arcad/arcast/pkg/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func Scan() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "scan",
|
||||
Flags: flag.ComposeFlags(
|
||||
&cli.DurationFlag{
|
||||
Name: "timeout",
|
||||
EnvVars: []string{"ARCAST_CLIENT_SCAN_TIMEOUT"},
|
||||
Usage: "Use `TIMEOUT` as maximum scan duration",
|
||||
Value: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
Action: func(ctx *cli.Context) error {
|
||||
baseFlags := flag.GetBaseFlags(ctx)
|
||||
timeout := ctx.Duration("timeout")
|
||||
|
||||
client := client.New()
|
||||
|
||||
scanCtx, cancel := context.WithTimeout(ctx.Context, timeout)
|
||||
defer cancel()
|
||||
|
||||
players, err := client.Scan(scanCtx)
|
||||
if err != nil && (!errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)) {
|
||||
return errors.Wrap(err, "could not scan for players")
|
||||
}
|
||||
|
||||
hints := playerHints(baseFlags.OutputMode)
|
||||
|
||||
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(players...)...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
53
internal/command/client/status.go
Normal file
53
internal/command/client/status.go
Normal file
@ -0,0 +1,53 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/internal/command/client/flag"
|
||||
"forge.cadoles.com/arcad/arcast/pkg/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func Status() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "status",
|
||||
Flags: flag.ComposeFlags(
|
||||
playerFlags...,
|
||||
),
|
||||
Action: func(ctx *cli.Context) error {
|
||||
baseFlags := flag.GetBaseFlags(ctx)
|
||||
|
||||
statuses := make([]wrappedStatus, 0)
|
||||
|
||||
err := forEachPlayer(ctx, func(cl *client.Client, playerAddr string) error {
|
||||
status, err := cl.Status(ctx.Context, playerAddr)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not cast")
|
||||
}
|
||||
|
||||
statuses = append(statuses, wrappedStatus{
|
||||
Status: status.Status,
|
||||
URL: status.URL,
|
||||
Title: status.Title,
|
||||
ID: status.ID,
|
||||
Address: playerAddr,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
hints := wrappedStatusHints(baseFlags.OutputMode)
|
||||
|
||||
if err := format.Write(baseFlags.Format, os.Stdout, hints, AsAnySlice(statuses...)...); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
91
internal/command/client/util.go
Normal file
91
internal/command/client/util.go
Normal file
@ -0,0 +1,91 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/client"
|
||||
"forge.cadoles.com/arcad/arcast/pkg/server"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
playerScanTimeoutFlag = "scan-timeout"
|
||||
flagPlayerAddr = "player-address"
|
||||
flagPlayerID = "player-id"
|
||||
playerFlags = []cli.Flag{
|
||||
&cli.DurationFlag{
|
||||
Name: playerScanTimeoutFlag,
|
||||
EnvVars: []string{"ARCAST_CLIENT_CAST_TIMEOUT"},
|
||||
Usage: "Use `TIMEOUT` as maximum cast duration",
|
||||
Value: 5 * time.Second,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: flagPlayerID,
|
||||
Aliases: []string{"i"},
|
||||
EnvVars: []string{"ARCAST_CLIENT_PLAYER_ID"},
|
||||
Usage: "Use `ID` as player id",
|
||||
Value: cli.NewStringSlice(),
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: flagPlayerAddr,
|
||||
Aliases: []string{"a"},
|
||||
EnvVars: []string{"ARCAST_CLIENT_PLAYER_ADDR"},
|
||||
Usage: "Use `ADDR` as player address",
|
||||
Value: cli.NewStringSlice(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func forEachPlayer(ctx *cli.Context, fn func(cl *client.Client, playerAddr string) error) error {
|
||||
playerAddrs := ctx.StringSlice(flagPlayerAddr)
|
||||
|
||||
cl := client.New()
|
||||
|
||||
if ctx.IsSet(flagPlayerID) || !ctx.IsSet(flagPlayerAddr) {
|
||||
scanTimeout := ctx.Duration(playerScanTimeoutFlag)
|
||||
scanCtx, cancel := context.WithTimeout(ctx.Context, scanTimeout)
|
||||
defer cancel()
|
||||
|
||||
playerIDs := ctx.StringSlice(flagPlayerID)
|
||||
|
||||
players, err := cl.Scan(scanCtx, client.WithPlayerIDs(playerIDs...))
|
||||
if err != nil && (!errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)) {
|
||||
return errors.Wrap(err, "could not scan for players")
|
||||
}
|
||||
|
||||
for _, p := range players {
|
||||
preferredIP, err := server.FindPreferredLocalAddress(p.IPs...)
|
||||
if err != nil {
|
||||
return errors.Errorf("could not retrieve player '%s' preferred address", p.ID)
|
||||
}
|
||||
|
||||
playerAddr := fmt.Sprintf("%s:%d", preferredIP, p.Port)
|
||||
playerAddrs = append(playerAddrs, playerAddr)
|
||||
}
|
||||
}
|
||||
|
||||
if len(playerAddrs) == 0 {
|
||||
return errors.New("no players found")
|
||||
}
|
||||
|
||||
for _, addr := range playerAddrs {
|
||||
if err := fn(cl, addr); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func AsAnySlice[T any](src ...T) []any {
|
||||
dst := make([]any, len(src))
|
||||
|
||||
for i, s := range src {
|
||||
dst[i] = s
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
84
internal/command/main.go
Normal file
84
internal/command/main.go
Normal file
@ -0,0 +1,84 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func Main(name string, usage string, commands ...*cli.Command) {
|
||||
app := &cli.App{
|
||||
Name: name,
|
||||
Usage: usage,
|
||||
Commands: commands,
|
||||
Before: func(ctx *cli.Context) error {
|
||||
workdir := ctx.String("workdir")
|
||||
// Switch to new working directory if defined
|
||||
if workdir != "" {
|
||||
if err := os.Chdir(workdir); err != nil {
|
||||
return errors.Wrap(err, "could not change working directory")
|
||||
}
|
||||
}
|
||||
|
||||
logLevel := ctx.String("log-level")
|
||||
switch logLevel {
|
||||
case "debug":
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
case "info":
|
||||
logger.SetLevel(logger.LevelInfo)
|
||||
case "warn":
|
||||
logger.SetLevel(logger.LevelWarn)
|
||||
case "error":
|
||||
logger.SetLevel(logger.LevelError)
|
||||
case "critical":
|
||||
logger.SetLevel(logger.LevelCritical)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "workdir",
|
||||
Value: "",
|
||||
EnvVars: []string{"ARCAST_WORKDIR"},
|
||||
Usage: "The working directory",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
EnvVars: []string{"ARCAST_DEBUG"},
|
||||
Usage: "Enable debug mode",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "log-level",
|
||||
EnvVars: []string{"ARCAST_LOG_LEVEL"},
|
||||
Usage: "Set logging level",
|
||||
Value: "info",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app.ExitErrHandler = func(ctx *cli.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
debug := ctx.Bool("debug")
|
||||
|
||||
if !debug {
|
||||
fmt.Printf("[ERROR] %+v\n", err)
|
||||
} else {
|
||||
fmt.Printf("%+v", err)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(cli.FlagsByName(app.Flags))
|
||||
sort.Sort(cli.CommandsByName(app.Commands))
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
12
internal/command/player/root.go
Normal file
12
internal/command/player/root.go
Normal file
@ -0,0 +1,12 @@
|
||||
package player
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "player",
|
||||
Subcommands: []*cli.Command{
|
||||
Run(),
|
||||
},
|
||||
}
|
||||
}
|
87
internal/command/player/run.go
Normal file
87
internal/command/player/run.go
Normal file
@ -0,0 +1,87 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/arcad/arcast/pkg/browser/lorca"
|
||||
"forge.cadoles.com/arcad/arcast/pkg/server"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func Run() *cli.Command {
|
||||
defaults := lorca.NewOptions()
|
||||
return &cli.Command{
|
||||
Name: "run",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "additional-chrome-arg",
|
||||
EnvVars: []string{"ARCAST_DESKTOP_ADDITIONAL_CHROME_ARGS"},
|
||||
Value: cli.NewStringSlice("incognito"),
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "window-height",
|
||||
Value: defaults.Height,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "window-width",
|
||||
Value: defaults.Width,
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
windowHeight := ctx.Int("window-height")
|
||||
windowWidth := ctx.Int("window-width")
|
||||
chromeArgs := addFlagsPrefix(ctx.StringSlice("additional-chrome-arg")...)
|
||||
|
||||
browser := lorca.NewBrowser(
|
||||
lorca.WithAdditionalChromeArgs(chromeArgs...),
|
||||
lorca.WithWindowSize(windowWidth, windowHeight),
|
||||
)
|
||||
|
||||
if err := browser.Start(); err != nil {
|
||||
return errors.Wrap(err, "could not start browser")
|
||||
}
|
||||
|
||||
go func() {
|
||||
browser.Wait()
|
||||
logger.Warn(ctx.Context, "browser was closed")
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
logger.Info(ctx.Context, "stopping browser")
|
||||
if err := browser.Stop(); err != nil {
|
||||
logger.Error(ctx.Context, "could not stop browser", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
server := server.New(browser)
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
return errors.Wrap(err, "could not start server")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := server.Stop(); err != nil {
|
||||
logger.Error(ctx.Context, "could not stop server", logger.CapturedE(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := server.Wait(); err != nil {
|
||||
return errors.Wrap(err, "could not wait for server")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func addFlagsPrefix(stripped ...string) []string {
|
||||
flags := make([]string, len(stripped))
|
||||
for i, s := range stripped {
|
||||
flags[i] = fmt.Sprintf("--%s", s)
|
||||
}
|
||||
return flags
|
||||
}
|
Reference in New Issue
Block a user