Server/Web: Support "copy" action button to copy a value to the clipboard

This commit is contained in:
binwiederhier
2026-02-08 14:20:03 -05:00
parent 65050ef4dc
commit 3f0a7b65ee
10 changed files with 384 additions and 22 deletions

View File

@@ -4,10 +4,11 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/v2/util"
"regexp"
"strings"
"unicode/utf8"
"heckel.io/ntfy/v2/util"
)
const (
@@ -20,12 +21,14 @@ const (
actionView = "view"
actionBroadcast = "broadcast"
actionHTTP = "http"
actionCopy = "copy"
)
var (
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
actionsWithURL = []string{actionView, actionHTTP}
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
)
type actionParser struct {
@@ -61,11 +64,13 @@ func parseActions(s string) (actions []*action, err error) {
}
for _, action := range actions {
if !util.Contains(actionsAll, action.Action) {
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
} else if action.Label == "" {
return nil, fmt.Errorf("parameter 'label' is required")
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
}
@@ -158,6 +163,8 @@ func populateAction(newAction *action, section int, key, value string) error {
key = "label"
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
key = "url"
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
key = "value"
}
// Validate
@@ -188,6 +195,8 @@ func populateAction(newAction *action, section int, key, value string) error {
newAction.Method = value
case "body":
newAction.Body = value
case "value":
newAction.Value = value
case "intent":
newAction.Intent = value
default:

View File

@@ -1,8 +1,9 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseActions(t *testing.T) {
@@ -132,6 +133,44 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `https://x.org`, actions[1].URL)
require.Equal(t, true, actions[1].Clear)
// Copy action (simple format)
actions, err = parseActions("copy, Copy code, 1234")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy code", actions[0].Label)
require.Equal(t, "1234", actions[0].Value)
// Copy action (JSON)
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy OTP", actions[0].Label)
require.Equal(t, "567890", actions[0].Value)
// Copy action with clear
actions, err = parseActions("copy, Copy code, 1234, clear=true")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy code", actions[0].Label)
require.Equal(t, "1234", actions[0].Value)
require.Equal(t, true, actions[0].Clear)
// Copy action with explicit value key
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy token", actions[0].Label)
require.Equal(t, "abc-123-def", actions[0].Value)
require.True(t, actions[0].Clear)
// Copy action without value (error)
_, err = parseActions("copy, Copy code")
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
// Invalid syntax
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
require.EqualError(t, err, "unexpected character 'x' at position 22")
@@ -146,7 +185,7 @@ func TestParseActions(t *testing.T) {
require.EqualError(t, err, "term 'what is this anyway' unknown")
_, err = parseActions(`fdsfdsf`)
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
require.EqualError(t, err, "key 'aaa' unknown")
@@ -173,7 +212,7 @@ func TestParseActions(t *testing.T) {
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
_, err = parseActions(`[ { "some": "object" } ]`)
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions("\x00\x01\xFFx\xFE")
require.EqualError(t, err, "invalid utf-8 string")

View File

@@ -86,7 +86,7 @@ type attachment struct {
type action struct {
ID string `json:"id"`
Action string `json:"action"` // "view", "broadcast", or "http"
Action string `json:"action"` // "view", "broadcast", "http", or "copy"
Label string `json:"label"` // action button label
Clear bool `json:"clear"` // clear notification after successful execution
URL string `json:"url,omitempty"` // used in "view" and "http" actions
@@ -95,6 +95,7 @@ type action struct {
Body string `json:"body,omitempty"` // used in "http" action
Intent string `json:"intent,omitempty"` // used in "broadcast" action
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
Value string `json:"value,omitempty"` // used in "copy" action
}
func newAction() *action {