1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

87
shoutrrr/cmd/docs/docs.go Normal file
View file

@ -0,0 +1,87 @@
package docs
import (
"fmt"
"log"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/router"
"github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd"
)
var (
serviceRouter router.ServiceRouter
services = serviceRouter.ListServices()
)
var Cmd = &cobra.Command{
Use: "docs",
Short: "Print documentation for services",
Run: Run,
Args: func(cmd *cobra.Command, args []string) error {
serviceList := strings.Join(services, ", ")
cmd.SetUsageTemplate(
cmd.UsageTemplate() + "\nAvailable services: \n " + serviceList + "\n",
)
return cobra.MinimumNArgs(1)(cmd, args)
},
ValidArgs: services,
}
func init() {
Cmd.Flags().StringP("format", "f", "console", "Output format")
}
func Run(cmd *cobra.Command, args []string) {
format, _ := cmd.Flags().GetString("format")
res := printDocs(format, args)
if res.ExitCode != 0 {
fmt.Fprintf(os.Stderr, "%s", res.Message)
}
os.Exit(res.ExitCode)
}
func printDocs(docFormat string, services []string) cmd.Result {
var renderer format.TreeRenderer
switch docFormat {
case "console":
renderer = format.ConsoleTreeRenderer{WithValues: false}
case "markdown":
renderer = format.MarkdownTreeRenderer{
HeaderPrefix: "### ",
PropsDescription: "Props can be either supplied using the params argument, or through the URL using \n`?key=value&key=value` etc.\n",
PropsEmptyMessage: "*The services does not support any query/param props*",
}
default:
return cmd.InvalidUsage("invalid format")
}
logger := log.New(os.Stderr, "", 0) // Concrete logger implementing types.StdLogger
for _, scheme := range services {
service, err := serviceRouter.NewService(scheme)
if err != nil {
return cmd.InvalidUsage("failed to init service: " + err.Error())
}
// Initialize the service to populate Config
dummyURL, _ := url.Parse(scheme + "://dummy@dummy.com")
if err := service.Initialize(dummyURL, logger); err != nil {
return cmd.InvalidUsage(fmt.Sprintf("failed to initialize service %q: %v", scheme, err))
}
config := format.GetServiceConfig(service)
configNode := format.GetConfigFormat(config)
fmt.Fprint(os.Stdout, renderer.RenderTree(configNode, scheme), "\n")
}
return cmd.Success
}

View file

@ -0,0 +1,53 @@
package cmd
const (
// ExSuccess is the exit code that signals that everything went as expected.
ExSuccess = 0
// ExUsage is the exit code that signals that the application was not started with the correct arguments.
ExUsage = 64
// ExUnavailable is the exit code that signals that the application failed to perform the intended task.
ExUnavailable = 69
// ExConfig is the exit code that signals that the task failed due to a configuration error.
ExConfig = 78
)
// Success is the empty Result that is used whenever the command ran successfully.
//
//nolint:errname
var Success = Result{}
// Result contains the final exit message and code for a CLI session.
//
//nolint:errname
type Result struct {
ExitCode int
Message string
}
func (e Result) Error() string {
return e.Message
}
// InvalidUsage returns a Result with the exit code ExUsage.
func InvalidUsage(message string) Result {
return Result{
ExUsage,
message,
}
}
// TaskUnavailable returns a Result with the exit code ExUnavailable.
func TaskUnavailable(message string) Result {
return Result{
ExUnavailable,
message,
}
}
// ConfigurationError returns a Result with the exit code ExConfig.
func ConfigurationError(message string) Result {
return Result{
ExConfig,
message,
}
}

View file

@ -0,0 +1,240 @@
package generate
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/nicholas-fedor/shoutrrr/pkg/generators"
"github.com/nicholas-fedor/shoutrrr/pkg/router"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// MaximumNArgs defines the maximum number of positional arguments allowed.
const MaximumNArgs = 2
// ErrNoServiceSpecified indicates that no service was provided for URL generation.
var (
ErrNoServiceSpecified = errors.New("no service specified")
)
// serviceRouter manages the creation of notification services.
var serviceRouter router.ServiceRouter
// Cmd generates a notification service URL from user input.
var Cmd = &cobra.Command{
Use: "generate",
Short: "Generates a notification service URL from user input",
Run: Run,
PreRun: loadArgsFromAltSources,
Args: cobra.MaximumNArgs(MaximumNArgs),
}
// loadArgsFromAltSources populates command flags from positional arguments if provided.
func loadArgsFromAltSources(cmd *cobra.Command, args []string) {
if len(args) > 0 {
_ = cmd.Flags().Set("service", args[0])
}
if len(args) > 1 {
_ = cmd.Flags().Set("generator", args[1])
}
}
// init initializes the command flags for the generate command.
func init() {
serviceRouter = router.ServiceRouter{}
Cmd.Flags().
StringP("service", "s", "", "Notification service to generate a URL for (e.g., discord, smtp)")
Cmd.Flags().
StringP("generator", "g", "basic", "Generator to use (e.g., basic, or service-specific)")
Cmd.Flags().
StringArrayP("property", "p", []string{}, "Configuration property in key=value format (e.g., token=abc123)")
Cmd.Flags().
BoolP("show-sensitive", "x", false, "Show sensitive data in the generated URL (default: masked)")
}
// maskSensitiveURL masks sensitive parts of a Shoutrrr URL based on the service schema.
func maskSensitiveURL(serviceSchema, urlStr string) string {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return urlStr // Return original URL if parsing fails
}
switch serviceSchema {
case "discord", "slack", "teams":
maskUser(parsedURL, "REDACTED")
case "smtp":
maskSMTPUser(parsedURL)
case "pushover":
maskPushoverQuery(parsedURL)
case "gotify":
maskGotifyQuery(parsedURL)
default:
maskGeneric(parsedURL)
}
return parsedURL.String()
}
// maskUser redacts the username in a URL with a placeholder.
func maskUser(parsedURL *url.URL, placeholder string) {
if parsedURL.User != nil {
parsedURL.User = url.User(placeholder)
}
}
// maskSMTPUser redacts the password in an SMTP URL, preserving the username.
func maskSMTPUser(parsedURL *url.URL) {
if parsedURL.User != nil {
parsedURL.User = url.UserPassword(parsedURL.User.Username(), "REDACTED")
}
}
// maskPushoverQuery redacts token and user query parameters in a Pushover URL.
func maskPushoverQuery(parsedURL *url.URL) {
queryParams := parsedURL.Query()
if queryParams.Get("token") != "" {
queryParams.Set("token", "REDACTED")
}
if queryParams.Get("user") != "" {
queryParams.Set("user", "REDACTED")
}
parsedURL.RawQuery = queryParams.Encode()
}
// maskGotifyQuery redacts the token query parameter in a Gotify URL.
func maskGotifyQuery(parsedURL *url.URL) {
queryParams := parsedURL.Query()
if queryParams.Get("token") != "" {
queryParams.Set("token", "REDACTED")
}
parsedURL.RawQuery = queryParams.Encode()
}
// maskGeneric redacts userinfo and all query parameters for unrecognized services.
func maskGeneric(parsedURL *url.URL) {
maskUser(parsedURL, "REDACTED")
queryParams := parsedURL.Query()
for key := range queryParams {
queryParams.Set(key, "REDACTED")
}
parsedURL.RawQuery = queryParams.Encode()
}
// Run executes the generate command, producing a notification service URL.
func Run(cmd *cobra.Command, _ []string) {
var service types.Service
var err error
serviceSchema, _ := cmd.Flags().GetString("service")
generatorName, _ := cmd.Flags().GetString("generator")
propertyFlags, _ := cmd.Flags().GetStringArray("property")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Parse properties into a key-value map.
props := make(map[string]string, len(propertyFlags))
for _, prop := range propertyFlags {
parts := strings.Split(prop, "=")
if len(parts) != MaximumNArgs {
fmt.Fprint(
color.Output,
"Invalid property key/value pair: ",
color.HiYellowString(prop),
"\n",
)
continue
}
props[parts[0]] = parts[1]
}
if len(propertyFlags) > 0 {
fmt.Fprint(color.Output, "\n") // Add spacing after property warnings
}
// Validate and create the service.
if serviceSchema == "" {
err = ErrNoServiceSpecified
} else {
service, err = serviceRouter.NewService(serviceSchema)
}
if err != nil {
fmt.Fprint(os.Stdout, "Error: ", err, "\n")
}
if service == nil {
services := serviceRouter.ListServices()
serviceList := strings.Join(services, ", ")
cmd.SetUsageTemplate(cmd.UsageTemplate() + "\nAvailable services:\n " + serviceList + "\n")
_ = cmd.Usage()
os.Exit(1)
}
// Determine the generator to use.
var generator types.Generator
generatorFlag := cmd.Flags().Lookup("generator")
if !generatorFlag.Changed {
// Use the service-specific default generator if available and no explicit generator is set.
generator, _ = generators.NewGenerator(serviceSchema)
}
if generator != nil {
generatorName = serviceSchema
} else {
var genErr error
generator, genErr = generators.NewGenerator(generatorName)
if genErr != nil {
fmt.Fprint(os.Stdout, "Error: ", genErr, "\n")
}
}
if generator == nil {
generatorList := strings.Join(generators.ListGenerators(), ", ")
cmd.SetUsageTemplate(
cmd.UsageTemplate() + "\nAvailable generators:\n " + generatorList + "\n",
)
_ = cmd.Usage()
os.Exit(1)
}
// Generate and display the URL.
fmt.Fprint(color.Output, "Generating URL for ", color.HiCyanString(serviceSchema))
fmt.Fprint(color.Output, " using ", color.HiMagentaString(generatorName), " generator\n")
serviceConfig, err := generator.Generate(service, props, cmd.Flags().Args())
if err != nil {
_, _ = fmt.Fprint(os.Stdout, "Error: ", err, "\n")
os.Exit(1)
}
fmt.Fprint(color.Output, "\n")
maskedURL := maskSensitiveURL(serviceSchema, serviceConfig.GetURL().String())
if showSensitive {
fmt.Fprint(os.Stdout, "URL: ", serviceConfig.GetURL().String(), "\n")
} else {
fmt.Fprint(os.Stdout, "URL: ", maskedURL, "\n")
}
}

134
shoutrrr/cmd/send/send.go Normal file
View file

@ -0,0 +1,134 @@
package send
import (
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/nicholas-fedor/shoutrrr/internal/dedupe"
internalUtil "github.com/nicholas-fedor/shoutrrr/internal/util"
"github.com/nicholas-fedor/shoutrrr/pkg/router"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
cli "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd"
)
// MaximumNArgs defines the maximum number of arguments accepted by the command.
const (
MaximumNArgs = 2
MaxMessageLength = 100
)
// Cmd sends a notification using a service URL.
var Cmd = &cobra.Command{
Use: "send",
Short: "Send a notification using a service url",
Args: cobra.MaximumNArgs(MaximumNArgs),
PreRun: internalUtil.LoadFlagsFromAltSources,
RunE: Run,
}
func init() {
Cmd.Flags().BoolP("verbose", "v", false, "")
Cmd.Flags().StringArrayP("url", "u", []string{}, "The notification url")
_ = Cmd.MarkFlagRequired("url")
Cmd.Flags().
StringP("message", "m", "", "The message to send to the notification url, or - to read message from stdin")
_ = Cmd.MarkFlagRequired("message")
Cmd.Flags().StringP("title", "t", "", "The title used for services that support it")
}
func logf(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
}
func run(cmd *cobra.Command) error {
flags := cmd.Flags()
verbose, _ := flags.GetBool("verbose")
urls, _ := flags.GetStringArray("url")
urls = dedupe.RemoveDuplicates(urls)
message, _ := flags.GetString("message")
title, _ := flags.GetString("title")
if message == "-" {
logf("Reading from STDIN...")
stringBuilder := strings.Builder{}
count, err := io.Copy(&stringBuilder, os.Stdin)
if err != nil {
return fmt.Errorf("failed to read message from stdin: %w", err)
}
logf("Read %d byte(s)", count)
message = stringBuilder.String()
}
var logger *log.Logger
if verbose {
urlsPrefix := "URLs:"
for i, url := range urls {
logf("%s %s", urlsPrefix, url)
if i == 0 {
// Only display "URLs:" prefix for first line, replace with indentation for the subsequent
urlsPrefix = strings.Repeat(" ", len(urlsPrefix))
}
}
logf("Message: %s", util.Ellipsis(message, MaxMessageLength))
if title != "" {
logf("Title: %v", title)
}
logger = log.New(os.Stderr, "SHOUTRRR ", log.LstdFlags)
} else {
logger = util.DiscardLogger
}
serviceRouter, err := router.New(logger, urls...)
if err != nil {
return cli.ConfigurationError(fmt.Sprintf("error invoking send: %s", err))
}
params := make(types.Params)
if title != "" {
params["title"] = title
}
errs := serviceRouter.SendAsync(message, &params)
for err := range errs {
if err != nil {
return cli.TaskUnavailable(err.Error())
}
logf("Notification sent")
}
return nil
}
// Run executes the send command and handles its result.
func Run(cmd *cobra.Command, _ []string) error {
err := run(cmd)
if err != nil {
var result cli.Result
if errors.As(err, &result) && result.ExitCode != cli.ExUsage {
// If the error is not related to CLI usage, report error and exit to avoid cobra error output
_, _ = fmt.Fprintln(os.Stderr, err.Error())
os.Exit(result.ExitCode)
}
}
return err
}

View file

@ -0,0 +1,63 @@
package verify
import (
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
internalUtil "github.com/nicholas-fedor/shoutrrr/internal/util"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/router"
)
// Cmd verifies the validity of a service url.
var Cmd = &cobra.Command{
Use: "verify",
Short: "Verify the validity of a notification service URL",
PreRun: internalUtil.LoadFlagsFromAltSources,
Run: Run,
Args: cobra.MaximumNArgs(1),
}
var serviceRouter router.ServiceRouter
func init() {
Cmd.Flags().StringP("url", "u", "", "The notification url")
_ = Cmd.MarkFlagRequired("url")
}
// Run the verify command.
func Run(cmd *cobra.Command, _ []string) {
URL, _ := cmd.Flags().GetString("url")
serviceRouter = router.ServiceRouter{}
service, err := serviceRouter.Locate(URL)
if err != nil {
wrappedErr := fmt.Errorf("locating service for URL: %w", err)
fmt.Fprint(os.Stdout, "error verifying URL: ", sanitizeError(wrappedErr), "\n")
os.Exit(1)
}
config := format.GetServiceConfig(service)
configNode := format.GetConfigFormat(config)
fmt.Fprint(color.Output, format.ColorFormatTree(configNode, true))
}
// sanitizeError removes sensitive details from an error message.
func sanitizeError(err error) string {
errStr := err.Error()
// Check for common error patterns without exposing URL details
if strings.Contains(errStr, "unknown service") {
return "service not recognized"
}
if strings.Contains(errStr, "parse") || strings.Contains(errStr, "invalid") {
return "invalid URL format"
}
// Fallback for other errors
return "unable to process URL"
}

35
shoutrrr/main.go Normal file
View file

@ -0,0 +1,35 @@
package main
import (
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/nicholas-fedor/shoutrrr/internal/meta"
"github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd"
"github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/docs"
"github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/generate"
"github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/send"
"github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/verify"
)
var cobraCmd = &cobra.Command{
Use: "shoutrrr",
Version: meta.Version,
Short: "Shoutrrr CLI",
}
func init() {
viper.AutomaticEnv()
cobraCmd.AddCommand(verify.Cmd)
cobraCmd.AddCommand(generate.Cmd)
cobraCmd.AddCommand(send.Cmd)
cobraCmd.AddCommand(docs.Cmd)
}
func main() {
if err := cobraCmd.Execute(); err != nil {
os.Exit(cmd.ExUsage)
}
}