Compare commits

...

7 Commits

Author SHA1 Message Date
Kostia R
a860dba61b
Merge 090432e596fa4e3e898799ebf090701ef188d556 into d258f01d59a3c1d54d512541fd0502509b08934d 2026-07-14 22:15:21 +02:00
Bernhard B.
d258f01d59
Merge pull request #869 from Gara-Dorta/json-schema-dowload
feat: download the json schemas from the signal-cli releases
2026-07-14 21:50:32 +02:00
Gara Dorta
b13a74f43e feat: download the json schemas from the signal-cli releases 2026-07-14 20:08:28 +02:00
Bernhard B.
be0842fdb2
Merge pull request #844 from Gara-Dorta/deploy-pages
feat: deploy pages with CI
2026-07-13 22:29:46 +02:00
ziggy
090432e596 Preserve INFO severity for signal-cli stderr 2026-07-10 18:16:19 +03:00
ziggy
1a43bdaca5 Validate message body ranges and harden receive JSON handling 2026-07-10 18:07:57 +03:00
Gara Dorta
6e1698e820 feat: deploy pages with CI
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 12:01:26 +01:00
10 changed files with 257 additions and 42 deletions

37
.github/workflows/deploy-pages.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Deploy API Docs to GitHub Pages
on:
release:
types:
- published
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Build static documentation bundle
run: src/docs/build-static-bundle.sh site "${{ github.event.release.tag_name }}"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: site
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

View File

@ -16,7 +16,7 @@ RUN dpkg-reconfigure debconf --frontend=noninteractive \
&& apt-get update \
&& apt-get -y install --no-install-recommends \
wget git locales zip unzip \
file build-essential libz-dev zlib1g-dev binutils openjdk-25-jdk \
file build-essential libz-dev zlib1g-dev binutils \
&& rm -rf /var/lib/apt/lists/*
#COPY ext/libraries/libsignal-client/v${LIBSIGNAL_CLIENT_VERSION} /tmp/libsignal-client-libraries
@ -40,15 +40,6 @@ ENV JAVA_OPTS="-Djdk.lang.Process.launchMechanism=vfork"
ENV LANG en_US.UTF-8
# building the jsonSchemas for armv7l unfortunately doesn't work, so as a temporary fix, do not build the json schema
# for armv7. should be removed as soon as https://github.com/AsamK/signal-cli/pull/2040 is merged
RUN if [ "$(uname -m)" != "armv7l" ]; then \
cd /tmp \
&& git clone https://github.com/AsamK/signal-cli.git --branch v${SIGNAL_CLI_VERSION} --single-branch signal-cli-source \
&& cd signal-cli-source \
&& ./gradlew jsonSchemas; \
fi;
RUN go install github.com/swaggo/swag/cmd/swag@v${SWAG_VERSION}
RUN cd /tmp/ \
@ -112,13 +103,11 @@ RUN cd /tmp/signal-cli-rest-api-src && ${GOPATH}/bin/swag init --requiredByDefau
# manually add the json schemas for the receive V1 endpoint to the docs
# (building the jsonSchemas for armv7l unfortunately doesn't work, so as a temporary fix, do not build the json schema
# for armv7. should be removed as soon as https://github.com/AsamK/signal-cli/pull/2040 is merged)
RUN if [ "$(uname -m)" != "armv7l" ]; then \
cd /tmp/signal-cli-rest-api-src/docs \
&& cp -r /tmp/signal-cli-source/build/generated/META-INF/schemas signal-cli-schemas \
&& go run add_v1_receive_schemas.go signal-cli-schemas; \
fi;
RUN cd /tmp/signal-cli-rest-api-src/docs \
&& wget https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-json-schemas.tar.gz \
&& mkdir signal-cli-schemas \
&& tar xf signal-cli-${SIGNAL_CLI_VERSION}-json-schemas.tar.gz -C signal-cli-schemas \
&& go run add_v1_receive_schemas.go signal-cli-schemas
# build signal-cli-rest-api
RUN cd /tmp/signal-cli-rest-api-src && go build -o signal-cli-rest-api main.go

View File

@ -4,11 +4,12 @@ import (
"bufio"
"bytes"
"errors"
utils "github.com/bbernhard/signal-cli-rest-api/utils"
log "github.com/sirupsen/logrus"
"os/exec"
"strings"
"time"
utils "github.com/bbernhard/signal-cli-rest-api/utils"
log "github.com/sirupsen/logrus"
)
type CliClient struct {
@ -49,6 +50,34 @@ func stripInfoAndWarnMessages(input string) (string, string, string) {
return output, infoMessages, warnMessages
}
func classifySignalCliOutput(stdout string, stderr string) (string, string, string) {
stdout = strings.TrimRight(stdout, "\r\n")
output, infoMessages, warnMessages := stripInfoAndWarnMessages(stdout)
stderr = strings.TrimSpace(stderr)
if stderr != "" {
stderrOutput, stderrInfoMessages, stderrWarnMessages := stripInfoAndWarnMessages(stderr)
if stderrInfoMessages != "" {
if infoMessages != "" {
infoMessages += "\n"
}
infoMessages += stderrInfoMessages
}
if stderrWarnMessages != "" {
if warnMessages != "" {
warnMessages += "\n"
}
warnMessages += stderrWarnMessages
}
if stderrOutput != "" {
if warnMessages != "" {
warnMessages += "\n"
}
warnMessages += stderrOutput
}
}
return output, infoMessages, warnMessages
}
func (s *CliClient) Execute(wait bool, args []string, stdin string) (string, error) {
containerId, err := getContainerId()
@ -132,17 +161,15 @@ func (s *CliClient) Execute(wait bool, args []string, stdin string) (string, err
return "", errors.New("process killed as timeout reached")
case err := <-done:
if err != nil {
combinedOutput := stdoutBuffer.String() + stderrBuffer.String()
log.Debug("signal-cli output (stdout): ", stdoutBuffer.String())
log.Debug("signal-cli output (stderr): ", stderrBuffer.String())
return "", errors.New(combinedOutput)
return "", errors.New(strings.TrimSpace(stdoutBuffer.String() + stderrBuffer.String()))
}
}
combinedOutput := stdoutBuffer.String() + stderrBuffer.String()
log.Debug("signal-cli output (stdout): ", stdoutBuffer.String())
log.Debug("signal-cli output (stderr): ", stderrBuffer.String())
strippedOutput, infoMessages, warnMessages := stripInfoAndWarnMessages(combinedOutput)
strippedOutput, infoMessages, warnMessages := classifySignalCliOutput(stdoutBuffer.String(), stderrBuffer.String())
for _, line := range strings.Split(infoMessages, "\n") {
if line != "" {
log.Info(line)

26
src/client/cli_test.go Normal file
View File

@ -0,0 +1,26 @@
package client
import (
"strings"
"testing"
)
func TestClassifySignalCliOutputKeepsStderrOutOfResponse(t *testing.T) {
stdout := "{\"account\":\"+380000000001\"}\n"
stderr := "INFO Manager - Routine status\nWARN IncomingMessageHandler - Invalid content! reason\njava.lang.Throwable\n\tat example"
output, infos, warnings := classifySignalCliOutput(stdout, stderr)
if output != strings.TrimSpace(stdout) {
t.Fatalf("got output %q, wanted %q", output, strings.TrimSpace(stdout))
}
if !strings.Contains(infos, "INFO Manager - Routine status") {
t.Fatalf("INFO stderr was not preserved at INFO severity: %q", infos)
}
if strings.Contains(warnings, "INFO Manager - Routine status") {
t.Fatalf("INFO stderr was promoted to warning severity: %q", warnings)
}
if !strings.Contains(warnings, "Invalid content! reason") || !strings.Contains(warnings, "java.lang.Throwable") {
t.Fatalf("warnings did not preserve stderr: %q", warnings)
}
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
@ -497,6 +498,18 @@ func (s *SignalClient) send(signalCliSendRequest ds.SignalCliSendRequest) (*Send
textstyleParser := utils.NewTextstyleParser(signalCliSendRequest.Message)
signalCliSendRequest.Message, signalCliTextFormatStrings = textstyleParser.Parse()
}
if err := validateBodyRanges(signalCliSendRequest.Message,
signalCliSendRequest.Mentions,
signalCliTextFormatStrings); err != nil {
return nil, err
}
if signalCliSendRequest.QuoteMessage != nil {
if err := validateMentionRanges(*signalCliSendRequest.QuoteMessage, signalCliSendRequest.QuoteMentions); err != nil {
return nil, fmt.Errorf("invalid quote mention: %w", err)
}
} else if len(signalCliSendRequest.QuoteMentions) > 0 {
return nil, errors.New("quote mentions require a quote message")
}
var groupId string = ""
if signalCliSendRequest.RecipientType == ds.Group {
@ -1053,22 +1066,71 @@ func (s *SignalClient) Receive(number string, timeout int64, ignoreAttachments b
return "", err
}
out = strings.Trim(out, "\n")
lines := strings.Split(out, "\n")
jsonStr := "["
for i, line := range lines {
jsonStr += line
if i != (len(lines) - 1) {
jsonStr += ","
}
}
jsonStr += "]"
return jsonStr, nil
return marshalJsonStream(out)
}
}
func marshalJsonStream(output string) (string, error) {
decoder := json.NewDecoder(strings.NewReader(output))
messages := make([]json.RawMessage, 0)
for {
var message json.RawMessage
if err := decoder.Decode(&message); err != nil {
if errors.Is(err, io.EOF) {
break
}
return "", fmt.Errorf("invalid JSON from signal-cli receive: %w", err)
}
messages = append(messages, message)
}
result, err := json.Marshal(messages)
if err != nil {
return "", fmt.Errorf("failed to marshal signal-cli receive response: %w", err)
}
return string(result), nil
}
func validateBodyRanges(message string, mentions []ds.MessageMention, textStyles []string) error {
if err := validateMentionRanges(message, mentions); err != nil {
return fmt.Errorf("invalid mention: %w", err)
}
bodyLength := int64(utils.UTF16StringLength(message))
for i, textStyle := range textStyles {
parts := strings.SplitN(textStyle, ":", 3)
if len(parts) != 3 {
return fmt.Errorf("invalid text style at index %d", i)
}
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid text style start at index %d: %w", i, err)
}
length, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return fmt.Errorf("invalid text style length at index %d: %w", i, err)
}
if !bodyRangeWithinBounds(start, length, bodyLength) {
return fmt.Errorf("text style at index %d is outside the final UTF-16 message length %d", i, bodyLength)
}
}
return nil
}
func validateMentionRanges(message string, mentions []ds.MessageMention) error {
bodyLength := int64(utils.UTF16StringLength(message))
for i, mention := range mentions {
if !bodyRangeWithinBounds(mention.Start, mention.Length, bodyLength) {
return fmt.Errorf("mention at index %d is outside the final UTF-16 message length %d", i, bodyLength)
}
}
return nil
}
func bodyRangeWithinBounds(start int64, length int64, bodyLength int64) bool {
return start >= 0 && length >= 0 && start <= bodyLength && length <= bodyLength-start
}
func (s *SignalClient) GetReceiveChannel() (chan JsonRpc2ReceivedMessage, string, error) {
jsonRpc2Client, err := s.getJsonRpc2Client()
if err != nil {

View File

@ -0,0 +1,50 @@
package client
import (
"encoding/json"
"testing"
ds "github.com/bbernhard/signal-cli-rest-api/datastructs"
utils "github.com/bbernhard/signal-cli-rest-api/utils"
)
func TestMarshalJsonStream(t *testing.T) {
output, err := marshalJsonStream("{\"account\":\"one\"}\n{\"account\":\"two\"}\n")
if err != nil {
t.Fatal(err)
}
var messages []map[string]string
if err := json.Unmarshal([]byte(output), &messages); err != nil {
t.Fatal(err)
}
if len(messages) != 2 || messages[0]["account"] != "one" || messages[1]["account"] != "two" {
t.Fatalf("unexpected messages: %#v", messages)
}
}
func TestMarshalJsonStreamRejectsTrailingStderr(t *testing.T) {
_, err := marshalJsonStream("{\"account\":\"one\"}\njava.lang.Throwable\n")
if err == nil {
t.Fatal("expected malformed trailing output to be rejected")
}
}
func TestValidateBodyRangesUsesFinalUTF16Length(t *testing.T) {
parser := utils.NewTextstyleParser("👋 **hello**")
message, styles := parser.Parse()
mentions := []ds.MessageMention{{Start: 3, Length: 5, Author: "aci"}}
if err := validateBodyRanges(message, mentions, styles); err != nil {
t.Fatal(err)
}
}
func TestValidateBodyRangesRejectsUTF8ByteOffsets(t *testing.T) {
message := "👋 hello"
mentions := []ds.MessageMention{{Start: 5, Length: 5, Author: "aci"}}
if err := validateBodyRanges(message, mentions, nil); err == nil {
t.Fatal("expected UTF-8 byte offset to exceed the UTF-16 message length")
}
}

View File

@ -45,7 +45,7 @@ Install [go](https://go.dev/).
cd docs
```
1. Add the signal-cli receive V1 schemas
* Download the `signal-cli-x.y.z-json-schemas.tar.gz` schema files from https://github.com/Gara-Dorta/signal-cli/releases
* Download the `signal-cli-x.y.z-json-schemas.tar.gz` schema files from https://github.com/AsamK/signal-cli/releases
* Extract the files
* Run the script to add the schemas
```bash

24
src/docs/build-static-bundle.sh Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DOCS_DIR="$ROOT_DIR/src/docs"
INDEX_FILE="$DOCS_DIR/index.html"
OUT_DIR="${1:?output directory argument is required}"
DOCS_VERSION="${2:?docs version argument is required}"
NORMALIZED_DOCS_VERSION="${DOCS_VERSION#v}"
echo "Normalized docs version: $NORMALIZED_DOCS_VERSION"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
cp "$DOCS_DIR/swagger.json" "$OUT_DIR/swagger.json"
cp "$INDEX_FILE" "$OUT_DIR/index.html"
touch "$OUT_DIR/.nojekyll"
jq --arg version "$NORMALIZED_DOCS_VERSION" '.info.version = $version' "$OUT_DIR/swagger.json" > "$OUT_DIR/swagger.json.tmp"
mv "$OUT_DIR/swagger.json.tmp" "$OUT_DIR/swagger.json"
echo "Static bundle created at: $OUT_DIR"

View File

@ -11,7 +11,7 @@
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "src/docs/swagger.json", //Location of Open API spec in the repo
url: "swagger.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [

View File

@ -27,7 +27,7 @@ const (
const EscapeCharacter rune = '\\'
func getUtf16StringLength(s string) int {
func UTF16StringLength(s string) int {
runes := []rune(s) //turn string to slice
length := 0
@ -115,13 +115,13 @@ func (l *TextstyleParser) peek() rune {
func (l *TextstyleParser) handleToken(tokenType int, signalCliStylingType string) {
if l.tokens.Empty() {
l.tokens.Push(TokenState{BeginPos: getUtf16StringLength(l.fullString), Token: tokenType})
l.tokens.Push(TokenState{BeginPos: UTF16StringLength(l.fullString), Token: tokenType})
} else {
if l.tokens.Peek().Token == tokenType {
tokenBeginState := l.tokens.Pop()
l.signalCliFormatStrings = append(l.signalCliFormatStrings, strconv.Itoa(tokenBeginState.BeginPos)+":"+strconv.Itoa(getUtf16StringLength(l.fullString)-tokenBeginState.BeginPos)+":"+signalCliStylingType)
l.signalCliFormatStrings = append(l.signalCliFormatStrings, strconv.Itoa(tokenBeginState.BeginPos)+":"+strconv.Itoa(UTF16StringLength(l.fullString)-tokenBeginState.BeginPos)+":"+signalCliStylingType)
} else {
l.tokens.Push(TokenState{BeginPos: getUtf16StringLength(l.fullString), Token: tokenType})
l.tokens.Push(TokenState{BeginPos: UTF16StringLength(l.fullString), Token: tokenType})
}
}
}