Make simple actions parsing work
parent
55869f551e
commit
5a9b2122c2
|
@ -39,6 +39,7 @@ var (
|
||||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||||
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||||
|
errHTTPBadRequestActionJSONInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions are invalid JSON", ""} // FIXME link
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
|
@ -29,6 +30,7 @@ const (
|
||||||
priority INT NOT NULL,
|
priority INT NOT NULL,
|
||||||
tags TEXT NOT NULL,
|
tags TEXT NOT NULL,
|
||||||
click TEXT NOT NULL,
|
click TEXT NOT NULL,
|
||||||
|
actions TEXT NOT NULL,
|
||||||
attachment_name TEXT NOT NULL,
|
attachment_name TEXT NOT NULL,
|
||||||
attachment_type TEXT NOT NULL,
|
attachment_type TEXT NOT NULL,
|
||||||
attachment_size INT NOT NULL,
|
attachment_size INT NOT NULL,
|
||||||
|
@ -43,37 +45,37 @@ const (
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDQuery = `
|
selectMessagesSinceIDQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
|
@ -228,6 +230,14 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||||
attachmentURL = m.Attachment.URL
|
attachmentURL = m.Attachment.URL
|
||||||
attachmentOwner = m.Attachment.Owner
|
attachmentOwner = m.Attachment.Owner
|
||||||
}
|
}
|
||||||
|
var actionsStr string
|
||||||
|
if len(m.Actions) > 0 {
|
||||||
|
actionsBytes, err := json.Marshal(m.Actions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actionsStr = string(actionsBytes)
|
||||||
|
}
|
||||||
_, err := c.db.Exec(
|
_, err := c.db.Exec(
|
||||||
insertMessageQuery,
|
insertMessageQuery,
|
||||||
m.ID,
|
m.ID,
|
||||||
|
@ -238,6 +248,7 @@ func (c *messageCache) AddMessage(m *message) error {
|
||||||
m.Priority,
|
m.Priority,
|
||||||
tags,
|
tags,
|
||||||
m.Click,
|
m.Click,
|
||||||
|
actionsStr,
|
||||||
attachmentName,
|
attachmentName,
|
||||||
attachmentType,
|
attachmentType,
|
||||||
attachmentSize,
|
attachmentSize,
|
||||||
|
@ -399,7 +410,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var timestamp, attachmentSize, attachmentExpires int64
|
var timestamp, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&id,
|
&id,
|
||||||
×tamp,
|
×tamp,
|
||||||
|
@ -409,6 +420,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
&priority,
|
&priority,
|
||||||
&tagsStr,
|
&tagsStr,
|
||||||
&click,
|
&click,
|
||||||
|
&actionsStr,
|
||||||
&attachmentName,
|
&attachmentName,
|
||||||
&attachmentType,
|
&attachmentType,
|
||||||
&attachmentSize,
|
&attachmentSize,
|
||||||
|
@ -424,6 +436,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
if tagsStr != "" {
|
if tagsStr != "" {
|
||||||
tags = strings.Split(tagsStr, ",")
|
tags = strings.Split(tagsStr, ",")
|
||||||
}
|
}
|
||||||
|
var actions []*action
|
||||||
|
if actionsStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
var att *attachment
|
var att *attachment
|
||||||
if attachmentName != "" && attachmentURL != "" {
|
if attachmentName != "" && attachmentURL != "" {
|
||||||
att = &attachment{
|
att = &attachment{
|
||||||
|
@ -445,6 +463,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Click: click,
|
Click: click,
|
||||||
|
Actions: actions,
|
||||||
Attachment: att,
|
Attachment: att,
|
||||||
Encoding: encoding,
|
Encoding: encoding,
|
||||||
})
|
})
|
||||||
|
|
|
@ -93,6 +93,7 @@ const (
|
||||||
emptyMessageBody = "triggered" // Used if message body is empty
|
emptyMessageBody = "triggered" // Used if message body is empty
|
||||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||||
encodingBase64 = "base64"
|
encodingBase64 = "base64"
|
||||||
|
actionIDLength = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
|
@ -537,14 +538,10 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||||
}
|
}
|
||||||
actionsStr := readParam(r, "x-actions", "actions", "action")
|
actionsStr := readParam(r, "x-actions", "actions", "action")
|
||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
actions := make([]action, 0)
|
m.Actions, err = parseActions(actionsStr)
|
||||||
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
|
if err != nil {
|
||||||
return false, false, "", false, errHTTPBadRequestDelayNoCache // FIXME error
|
return false, false, "", false, errHTTPBadRequestActionJSONInvalid
|
||||||
}
|
}
|
||||||
for i := range actions {
|
|
||||||
actions[i].ID = util.RandomString(10) // FIXME
|
|
||||||
}
|
|
||||||
m.Actions = actions
|
|
||||||
}
|
}
|
||||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
if unifiedpush {
|
if unifiedpush {
|
||||||
|
|
|
@ -27,13 +27,15 @@ type message struct {
|
||||||
Priority int `json:"priority,omitempty"`
|
Priority int `json:"priority,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Click string `json:"click,omitempty"`
|
Click string `json:"click,omitempty"`
|
||||||
Actions []action `json:"actions,omitempty"`
|
Actions []*action `json:"actions,omitempty"`
|
||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME persist actions
|
||||||
|
|
||||||
type attachment struct {
|
type attachment struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
|
@ -46,11 +48,12 @@ type attachment struct {
|
||||||
type action struct {
|
type action struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"` // "view", "broadcast", or "http"
|
||||||
URL string `json:"url,omitempty"` // used in "view" and "http"
|
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
||||||
Method string `json:"method,omitempty"` // used in "http"
|
Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
|
||||||
Headers map[string]string `json:"headers,omitempty"` // used in "http"
|
Headers map[string]string `json:"headers,omitempty"` // used in "http" action
|
||||||
Body string `json:"body,omitempty"` // used in "http"
|
Body string `json:"body,omitempty"` // used in "http" action
|
||||||
|
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishMessage is used as input when publishing as JSON
|
// publishMessage is used as input when publishing as JSON
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
@ -40,3 +44,73 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseActions(s string) (actions []*action, err error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "[") {
|
||||||
|
actions, err = parseActionsFromJSON(s)
|
||||||
|
} else {
|
||||||
|
actions, err = parseActionsFromSimple(s)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range actions {
|
||||||
|
actions[i].ID = util.RandomString(actionIDLength)
|
||||||
|
if !util.InStringList([]string{"view", "broadcast", "http"}, actions[i].Action) {
|
||||||
|
return nil, fmt.Errorf("cannot parse actions: action '%s' unknown", actions[i].Action)
|
||||||
|
} else if actions[i].Label == "" {
|
||||||
|
return nil, fmt.Errorf("cannot parse actions: label must be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||||
|
actions := make([]*action, 0)
|
||||||
|
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||||
|
actions := make([]*action, 0)
|
||||||
|
rawActions := util.SplitNoEmpty(s, ";")
|
||||||
|
for _, rawAction := range rawActions {
|
||||||
|
newAction := &action{}
|
||||||
|
parts := util.SplitNoEmpty(rawAction, ",")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil, fmt.Errorf("cannot parse action: action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
|
||||||
|
}
|
||||||
|
for i, part := range parts {
|
||||||
|
key, value := util.SplitKV(part, "=")
|
||||||
|
if key == "" && i == 0 {
|
||||||
|
newAction.Action = value
|
||||||
|
} else if key == "" && i == 1 {
|
||||||
|
newAction.Label = value
|
||||||
|
} else if key == "" && i == 2 {
|
||||||
|
newAction.URL = value // This works, because both "http" and "view" need a URL
|
||||||
|
} else if key != "" {
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "action":
|
||||||
|
newAction.Action = value
|
||||||
|
case "label":
|
||||||
|
newAction.Label = value
|
||||||
|
case "url":
|
||||||
|
newAction.URL = value
|
||||||
|
case "method":
|
||||||
|
newAction.Method = value
|
||||||
|
case "body":
|
||||||
|
newAction.Body = value
|
||||||
|
default:
|
||||||
|
return nil, errors.Errorf("cannot parse action: key '%s' not supported, please use JSON format instead", part)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, errors.Errorf("cannot parse action: unknown phrase '%s'", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions = append(actions, newAction)
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
|
@ -27,3 +27,38 @@ func TestReadBoolParam(t *testing.T) {
|
||||||
require.Equal(t, false, up)
|
require.Equal(t, false, up)
|
||||||
require.Equal(t, true, firebase)
|
require.Equal(t, true, firebase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseActions(t *testing.T) {
|
||||||
|
actions, err := parseActions("[]")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, actions)
|
||||||
|
|
||||||
|
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, "Show portal", actions[1].Label)
|
||||||
|
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||||
|
|
||||||
|
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "view", actions[1].Action)
|
||||||
|
require.Equal(t, "Show portal", actions[1].Label)
|
||||||
|
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||||
|
|
||||||
|
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(actions))
|
||||||
|
require.Equal(t, "http", actions[0].Action)
|
||||||
|
require.Equal(t, "Open door", actions[0].Label)
|
||||||
|
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||||
|
require.Equal(t, "PUT", actions[0].Method)
|
||||||
|
require.Equal(t, "this is a body", actions[0].Body)
|
||||||
|
}
|
||||||
|
|
10
util/util.go
10
util/util.go
|
@ -77,6 +77,16 @@ func SplitNoEmpty(s string, sep string) []string {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SplitKV splits a string into a key/value pair using a separator, and trimming space. If the separator
|
||||||
|
// is not found, key is empty.
|
||||||
|
func SplitKV(s string, sep string) (key string, value string) {
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(s), sep, 2)
|
||||||
|
if len(kv) == 2 {
|
||||||
|
return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
|
||||||
|
}
|
||||||
|
return "", strings.TrimSpace(kv[0])
|
||||||
|
}
|
||||||
|
|
||||||
// RandomString returns a random string with a given length
|
// RandomString returns a random string with a given length
|
||||||
func RandomString(length int) string {
|
func RandomString(length int) string {
|
||||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||||
|
|
Loading…
Reference in New Issue