Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
root = true
[*]
indent_style = space
indent_size = 2
tab_width = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.go]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
indent_size = 1
[Makefile]
indent_style = tab
@@ -0,0 +1 @@
* text=auto eol=lf
@@ -0,0 +1,16 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Misc
.DS_Store
logs.txt
+76
View File
@@ -0,0 +1,76 @@
linters-settings:
gofmt:
simplify: true
rewrite-rules:
- pattern: interface{}
replacement: any
misspell:
locale: US
gofumpt:
extra-rules: true
forbidigo:
forbid:
- context\.WithCancel$
- ^print.*$
- panic
- ^log.Fatal().*$
errorlint:
errorf-multi: true
gci:
custom-order: true
sections:
- standard
- default
- prefix(github.com/opencloud-eu/woodpecker-ci-config-service)
godot:
scope: toplevel
period: true
linters:
disable-all: true
enable:
- bidichk
- errcheck
- gofmt
- gosimple
- goimports
- govet
- ineffassign
- misspell
- revive
- staticcheck
- typecheck
- unused
- gofumpt
- errorlint
- forbidigo
- zerologlint
- asciicheck
- dogsled
- durationcheck
- errchkjson
- goheader
- gomoddirectives
- gomodguard
- goprintffuncname
- importas
- makezero
- rowserrcheck
- sqlclosecheck
- usetesting
- unconvert
- unparam
- wastedassign
- whitespace
- gocritic
- nolintlint
- stylecheck
- contextcheck
- forcetypeassert
- gci
- godot
run:
timeout: 15m
build-tags:
- test
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+19
View File
@@ -0,0 +1,19 @@
.PHONY: lint
lint:
go tool golangci-lint run
.PHONY: vendor
vendor:
go mod tidy
go mod vendor
format:
go tool gofumpt -extra -w .
.PHONY: clean
clean:
go clean -i ./...
.PHONY: test
test:
go test -race -cover -coverprofile coverage.out -timeout 60s .
+18
View File
@@ -0,0 +1,18 @@
# icap-client
[![status-badge](https://ci.opencloud.eu/api/badges/21/status.svg)](https://ci.opencloud.eu/repos/21)
[![Matrix](https://img.shields.io/matrix/opencloud%3Amatrix.org?logo=matrix)](https://app.element.io/#/room/#opencloud:matrix.org)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
## Contributing
Please follow the [OpenCloud Contribution Guidelines](https://github.com/opencloud-eu/opencloud/blob/main/CONTRIBUTING.md), which apply to all projects in the OpenCloud organization.
## Acknowledgements
This project is forked from [@egirna/icap-client](https://github.com/egirna/icap-client).
Special thanks to [egirna](https://github.com/egirna) for the original implementation and ongoing inspiration.
### License
**icap-client** is licensed under the [Apache License](LICENSE).
+81
View File
@@ -0,0 +1,81 @@
package icapclient
import (
"bufio"
"bytes"
"errors"
"net/http"
"strings"
)
// Client represents the ICAP client who makes the ICAP server calls.
type Client struct {
config Config // Store config for connection parameters
}
// NewClient creates a new ICAP client (no persistent connection).
func NewClient(options ...ConfigOption) (Client, error) {
config := DefaultConfig()
for _, option := range options {
option(&config)
}
return Client{config: config}, nil
}
// Do make the ICAP request, creating and dropping a connection each time.
func (c Client) Do(req Request) (res Response, err error) {
conn, err := NewICAPConn(c.config.ICAPConn)
if err != nil {
return Response{}, err
}
if err := conn.Connect(req.ctx, req.URL.Host); err != nil {
return Response{}, err
}
defer func() {
err = errors.Join(err, conn.Close())
}()
req.setDefaultRequestHeaders()
message, err := toICAPRequest(req)
if err != nil {
return Response{}, err
}
// send the ICAP message to the server
dataRes, err := conn.Send(message)
if err != nil {
return Response{}, err
}
res, err = toClientResponse(bufio.NewReader(strings.NewReader(string(dataRes))))
if err != nil {
return Response{}, err
}
// check if the message is fully done scanning or if it needs to be sent another chunk.
done := !(res.StatusCode == http.StatusContinue && !req.bodyFittedInPreview && req.previewSet)
if done {
return res, nil
}
// get the remaining body bytes.
data := req.remainingPreviewBytes
if !bodyIsChunked(string(data)) {
data = []byte(addHexBodyByteNotations(string(data)))
}
// hydrate the ICAP message with closing doubleCRLF suffix.
if !bytes.HasSuffix(data, []byte(doubleCRLF)) {
data = append(data, []byte(crlf)...)
}
// send the remaining body bytes to the server.
dataRes, err = conn.Send(data)
if err != nil {
return Response{}, err
}
return toClientResponse(bufio.NewReader(strings.NewReader(string(dataRes))))
}
+33
View File
@@ -0,0 +1,33 @@
package icapclient
import (
"time"
)
// Config is the shared configuration for the icap client library.
type Config struct {
ICAPConn ICAPConnConfig
}
// DefaultConfig returns the default configuration for the icap client library.
func DefaultConfig() Config {
return Config{
ICAPConn: ICAPConnConfig{
Timeout: 15 * time.Second,
},
}
}
// ConfigOption is a function that configures the icap client.
type ConfigOption func(*Config)
// WithICAPConnectionTimeout sets the timeout for the connection to the icap server.
func WithICAPConnectionTimeout(timeout time.Duration) ConfigOption {
return func(cfg *Config) {
if timeout <= 0 {
return
}
cfg.ICAPConn.Timeout = timeout
}
}
+109
View File
@@ -0,0 +1,109 @@
package icapclient
import (
"bytes"
"context"
"errors"
"io"
"net"
"sync"
"time"
)
// ICAPConnConfig is the configuration for the icap connection.
type ICAPConnConfig struct {
// Timeout is the maximum amount of time a connection will be kept open
Timeout time.Duration
}
// ICAPConn manages the transport layer for ICAP protocol.
type ICAPConn struct {
tcp net.Conn
mu sync.Mutex
timeout time.Duration
}
// NewICAPConn creates a new connection configuration.
func NewICAPConn(conf ICAPConnConfig) (*ICAPConn, error) {
return &ICAPConn{
timeout: conf.Timeout,
}, nil
}
// Connect connects to the ICAP server.
func (c *ICAPConn) Connect(ctx context.Context, address string) error {
dialer := net.Dialer{Timeout: c.timeout}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
c.tcp = conn
if c.timeout > 0 {
deadline := time.Now().Add(c.timeout)
if err := c.tcp.SetDeadline(deadline); err != nil {
return err
}
}
return nil
}
// Send sends a request to the ICAP server and reads the response.
func (c *ICAPConn) Send(in []byte) ([]byte, error) {
if !c.ok() {
return nil, ErrInvalidConnection
}
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.tcp.Write(in)
if err != nil {
return nil, err
}
var data []byte
buf := make([]byte, 4096)
for {
n, err := c.tcp.Read(buf)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
if errors.Is(err, io.EOF) || n == 0 {
break
}
data = append(data, buf[:n]...)
// Protocol checks for message termination
{
if bytes.Equal(data, []byte(icap100ContinueMsg)) {
break
}
if bytes.HasSuffix(data, []byte(doubleCRLF)) {
break
}
if bytes.Contains(data, []byte(icap204NoModsMsg)) {
break
}
}
}
return data, nil
}
// Close closes the TCP connection.
func (c *ICAPConn) Close() error {
if !c.ok() {
return ErrInvalidConnection
}
return c.tcp.Close()
}
func (c *ICAPConn) ok() bool {
return c != nil && c.tcp != nil
}
+4
View File
@@ -0,0 +1,4 @@
// Package icapclient is a client package for the ICAP protocol
//
// See https://github.com/egirna/icap-client/examples.
package icapclient
+432
View File
@@ -0,0 +1,432 @@
package icapclient
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"regexp"
"strconv"
"strings"
)
// the icap request methods.
const (
MethodOPTIONS = "OPTIONS"
MethodRESPMOD = "RESPMOD"
MethodREQMOD = "REQMOD"
)
// shared errors.
var (
// ErrNoContext is used when no context is provided.
ErrNoContext = errors.New("no context provided")
// ErrInvalidScheme is used when the url scheme is not icap://.
ErrInvalidScheme = errors.New("the url scheme must be icap://")
// ErrMethodNotAllowed is used when the method is not allowed.
ErrMethodNotAllowed = errors.New("the requested method is not registered")
// ErrInvalidHost is used when the host is invalid.
ErrInvalidHost = errors.New("the requested host is invalid")
// ErrInvalidMsg is used when the tcp message is invalid.
ErrInvalidMsg = errors.New("invalid message")
// ErrInvalidConnection is used when the connection is invalid.
ErrInvalidConnection = errors.New("invalid connection")
// ErrREQMODWithoutReq is used when the request is nil for REQMOD method.
ErrREQMODWithoutReq = errors.New("http request cannot be nil for method REQMOD")
// ErrREQMODWithResp is used when the response is not nil for REQMOD method.
ErrREQMODWithResp = errors.New("http response must be nil for method REQMOD")
// ErrRESPMODWithoutResp is used when the response is nil for RESPMOD method.
ErrRESPMODWithoutResp = errors.New("http response cannot be nil for method RESPMOD")
)
// general constants required for the package.
const (
schemeICAP = "icap"
icapVersion = "ICAP/1.0"
httpVersion = "HTTP/1.1"
schemeHTTPReq = "http_request"
schemeHTTPResp = "http_response"
crlf = "\r\n"
doubleCRLF = crlf + crlf
lf = "\n"
bodyEndIndicator = crlf + "0" + crlf
fullBodyEndIndicatorPreviewMode = "; ieof" + doubleCRLF
icap100ContinueMsg = "ICAP/1.0 100 Continue" + doubleCRLF
icap204NoModsMsg = "ICAP/1.0 204 Unmodified"
)
// Common ICAP headers.
const (
previewHeader = "Preview"
encapsulatedHeader = "Encapsulated"
)
// Conn represents the connection to the icap server.
type Conn interface {
io.Closer
Connect(ctx context.Context, address string) error
Send(in []byte) ([]byte, error)
}
// Response represents the icap server response data.
type Response struct {
StatusCode int
Status string
PreviewBytes int
Header http.Header
ContentRequest *http.Request
ContentResponse *http.Response
}
// getStatusWithCode prepares the status code and status text from two given strings.
func getStatusWithCode(str1, str2 string) (int, string, error) {
statusCode, err := strconv.Atoi(str1)
if err != nil {
return 0, "", err
}
status := strings.TrimSpace(str2)
return statusCode, status, nil
}
// getHeaderValue parses the header and its value from a tcp message string.
func getHeaderValue(str string) (string, string) {
headerValues := strings.SplitN(str, ":", 2)
header := headerValues[0]
if len(headerValues) >= 2 {
return header, strings.TrimSpace(headerValues[1])
}
return header, ""
}
// isRequestLine determines if the tcp message string is a request line, i.e., the first line of the message or not.
func isRequestLine(str string) bool {
return strings.Contains(str, icapVersion) || strings.Contains(str, httpVersion)
}
// setEncapsulatedHeaderValue generates the Encapsulated values and assigns to the ICAP request string.
func setEncapsulatedHeaderValue(icapReqStr, httpReqStr, httpRespStr string) string {
encVal := " "
if strings.HasPrefix(icapReqStr, MethodOPTIONS) {
switch {
// the most common case for OPTIONS method, no Encapsulated body
case httpReqStr == "" && httpRespStr == "":
encVal += "null-body=0"
// if there is an Encapsulated body
default:
encVal += "opt-body=0"
}
}
if strings.HasPrefix(icapReqStr, MethodREQMOD) || strings.HasPrefix(icapReqStr, MethodRESPMOD) {
// looking for the match of the string \r\n\r\n,
// as that is the expression that separates each block, i.e., headers and bodies
re := regexp.MustCompile(doubleCRLF)
// getting the offsets of the matches, tells us the starting/ending point of headers and bodies
reqIndices := re.FindAllStringIndex(httpReqStr, -1)
// is needed to calculate the response headers by adding the last offset of the request block
reqEndsAt := 0
if reqIndices != nil {
encVal += "req-hdr=0"
reqEndsAt = reqIndices[0][1]
switch {
// indicating there is a body present for the request block, as length would have been 1 for a single match of \r\n\r\n
case len(reqIndices) > 1:
encVal += fmt.Sprintf(", req-body=%d", reqIndices[0][1]) // assigning the starting point of the body
reqEndsAt = reqIndices[1][1]
case httpRespStr == "":
encVal += fmt.Sprintf(", null-body=%d", reqIndices[0][1])
}
if httpRespStr != "" {
encVal += ", "
}
}
respIndices := re.FindAllStringIndex(httpRespStr, -1)
if respIndices != nil {
encVal += fmt.Sprintf("res-hdr=%d", reqEndsAt)
switch {
case len(respIndices) > 1:
encVal += fmt.Sprintf(", res-body=%d", reqEndsAt+respIndices[0][1])
default:
encVal += fmt.Sprintf(", null-body=%d", reqEndsAt+respIndices[0][1])
}
}
}
// formatting the ICAP request Encapsulated header with the value
return fmt.Sprintf(icapReqStr, encVal)
}
// replaceRequestURIWithActualURL replaces just the escaped portion of the url with the entire URL in the dumped request message.
func replaceRequestURIWithActualURL(str, uri, url string) string {
if uri == "" {
uri = "/"
}
return strings.Replace(str, uri, url, 1)
}
// addFullBodyInPreviewIndicator adds 0; ieof\r\n\r\n which indicates the entire body fitted in the preview.
func addFullBodyInPreviewIndicator(str string) string {
return strings.TrimSuffix(str, doubleCRLF) + fullBodyEndIndicatorPreviewMode
}
// splitBodyAndHeader separates header and body from a http message.
func splitBodyAndHeader(str string) (string, string, bool) {
ss := strings.SplitN(str, doubleCRLF, 2)
if len(ss) < 2 || ss[1] == "" {
return "", "", false
}
headerStr := ss[0]
bodyStr := ss[1]
return headerStr, bodyStr, true
}
// bodyIsChunked determines if the http body is already chunked from the origin server or not.
func bodyIsChunked(str string) bool {
_, bodyStr, ok := splitBodyAndHeader(str)
if !ok {
return false
}
return regexp.MustCompile(`\r\n0(\r\n)+$`).MatchString(bodyStr)
}
// parsePreviewBodyBytes parses the preview portion of the body and only keeps that in the message.
func parsePreviewBodyBytes(str string, pb int) string {
headerStr, bodyStr, ok := splitBodyAndHeader(str)
if !ok {
return str
}
return headerStr + doubleCRLF + bodyStr[:pb]
}
// 0.
func addHexBodyByteNotations(str string) string {
return fmt.Sprintf("%x%s%s%s", len([]byte(str)), crlf, str, bodyEndIndicator)
}
// addHeaderAndBody merges the header and body of the http message.
func addHeaderAndBody(headerStr, bodyStr string) string {
return headerStr + doubleCRLF + bodyStr
}
// toICAPRequest returns the given request in its ICAP/1.x wire.
func toICAPRequest(req Request) ([]byte, error) {
// Making the ICAP message block
reqStr := fmt.Sprintf("%s %s %s%s", req.Method, req.URL.String(), icapVersion, crlf)
for headerName, values := range req.Header {
for _, value := range values {
reqStr += fmt.Sprintf("%s: %s%s", headerName, value, crlf)
}
}
// will populate the Encapsulated header value after making the http Request & Response messages
reqStr += "Encapsulated: %s" + crlf
reqStr += crlf
// build the HTTP Request message block
httpReqStr := ""
if req.HTTPRequest != nil {
b, err := httputil.DumpRequestOut(req.HTTPRequest, true)
if err != nil {
return nil, err
}
httpReqStr += string(b)
httpReqStr = replaceRequestURIWithActualURL(httpReqStr, req.HTTPRequest.URL.EscapedPath(), req.HTTPRequest.URL.String())
if req.Method == MethodREQMOD {
if req.previewSet {
httpReqStr = parsePreviewBodyBytes(httpReqStr, req.PreviewBytes)
}
if !bodyIsChunked(httpReqStr) {
headerStr, bodyStr, ok := splitBodyAndHeader(httpReqStr)
if ok {
bodyStr = addHexBodyByteNotations(bodyStr)
httpReqStr = addHeaderAndBody(headerStr, bodyStr)
}
}
}
// if the HTTP Request message block doesn't end with a \r\n\r\n,
// then going to add one by force for better calculation of byte offsets
if httpReqStr != "" {
for !strings.HasSuffix(httpReqStr, doubleCRLF) {
httpReqStr += crlf
}
}
}
// build the HTTP Response message block
httpRespStr := ""
if req.HTTPResponse != nil {
b, err := httputil.DumpResponse(req.HTTPResponse, true)
if err != nil {
return nil, err
}
httpRespStr += string(b)
if req.previewSet {
httpRespStr = parsePreviewBodyBytes(httpRespStr, req.PreviewBytes)
}
if !bodyIsChunked(httpRespStr) {
headerStr, bodyStr, ok := splitBodyAndHeader(httpRespStr)
if ok {
bodyStr = addHexBodyByteNotations(bodyStr)
httpRespStr = addHeaderAndBody(headerStr, bodyStr)
}
}
if httpRespStr != "" && !strings.HasSuffix(httpRespStr, doubleCRLF) { // if the HTTP Response message block doesn't end with a \r\n\r\n, then going to add one by force for better calculation of byte offsets
httpRespStr += crlf
}
}
if encVal := req.Header.Get(encapsulatedHeader); encVal != "" {
reqStr = fmt.Sprintf(reqStr, encVal)
} else {
// populating the Encapsulated header of the ICAP message portion
reqStr = setEncapsulatedHeaderValue(reqStr, httpReqStr, httpRespStr)
}
// determining if the http message needs the full body fitted in the preview portion indicator or not
if httpRespStr != "" && req.previewSet && req.bodyFittedInPreview {
httpRespStr = addFullBodyInPreviewIndicator(httpRespStr)
}
if req.Method == MethodREQMOD && req.previewSet && req.bodyFittedInPreview {
httpReqStr = addFullBodyInPreviewIndicator(httpReqStr)
}
data := []byte(reqStr + httpReqStr + httpRespStr)
return data, nil
}
// toClientResponse reads an ICAP message and returns a Response.
func toClientResponse(b *bufio.Reader) (Response, error) {
resp := Response{
Header: make(map[string][]string),
}
scheme := ""
httpMsg := ""
for currentMsg, err := b.ReadString('\n'); err == nil || currentMsg != ""; currentMsg, err = b.ReadString('\n') { // keep reading the buffer message which is the http response message
// if the current message line if the first line of the message portion(request line)
if isRequestLine(currentMsg) {
ss := strings.Split(currentMsg, " ")
// must contain 3 words, for example, "ICAP/1.0 200 OK" or "GET /something HTTP/1.1"
if len(ss) < 3 {
return Response{}, fmt.Errorf("%w: %s", ErrInvalidMsg, currentMsg)
}
// preparing the scheme below
if ss[0] == icapVersion {
scheme = schemeICAP
resp.StatusCode, resp.Status, err = getStatusWithCode(ss[1], strings.Join(ss[2:], " "))
if err != nil {
return Response{}, err
}
continue
}
if ss[0] == httpVersion {
scheme = schemeHTTPResp
httpMsg = ""
}
// http request message scheme version should always be at the end,
// for example, GET /something HTTP/1.1
if strings.TrimSpace(ss[2]) == httpVersion {
scheme = schemeHTTPReq
httpMsg = ""
}
}
// preparing the header for ICAP & contents for the HTTP messages below
if scheme == schemeICAP {
// ignore the CRLF and the LF, shouldn't be counted
if currentMsg == lf || currentMsg == crlf {
continue
}
header, val := getHeaderValue(currentMsg)
if header == previewHeader {
pb, _ := strconv.Atoi(val)
resp.PreviewBytes = pb
}
resp.Header.Add(header, val)
}
if scheme == schemeHTTPReq {
httpMsg += strings.TrimSpace(currentMsg) + crlf
bufferEmpty := b.Buffered() == 0
// a crlf indicates the end of the HTTP message,
// and the buffer check is just in case the buffer ended with one last message instead of a crlf
if currentMsg == crlf || bufferEmpty {
request, err := http.ReadRequest(bufio.NewReader(strings.NewReader(httpMsg)))
if err != nil {
return Response{}, err
}
resp.ContentRequest = request
continue
}
}
if scheme == schemeHTTPResp {
httpMsg += strings.TrimSpace(currentMsg) + crlf
bufferEmpty := b.Buffered() == 0
if currentMsg == crlf || bufferEmpty {
response, err := http.ReadResponse(bufio.NewReader(strings.NewReader(httpMsg)), resp.ContentRequest)
if err != nil {
return Response{}, err
}
resp.ContentResponse = response
continue
}
}
}
return resp, nil
}
+219
View File
@@ -0,0 +1,219 @@
package icapclient
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
)
// Request represents the icap client request data.
type Request struct {
Method string
URL *url.URL
Header http.Header
HTTPRequest *http.Request
HTTPResponse *http.Response
ChunkLength int
PreviewBytes int
ctx context.Context
previewSet bool
bodyFittedInPreview bool
remainingPreviewBytes []byte
}
// NewRequest returns a new Request given a context, method, url, http request and http response
// todo: method iota
func NewRequest(ctx context.Context, method, urlStr string, httpReq *http.Request, httpResp *http.Response) (Request, error) {
u, err := url.Parse(urlStr)
if err != nil {
return Request{}, err
}
req := Request{
Method: strings.ToUpper(method),
URL: u,
Header: make(map[string][]string),
HTTPRequest: httpReq,
HTTPResponse: httpResp,
ctx: ctx,
}
if err := req.validate(); err != nil {
return Request{}, err
}
return req, nil
}
// SetPreview sets the preview bytes in the icap header
// todo: defer close error
func (r *Request) SetPreview(maxBytes int) (err error) {
var bodyBytes []byte
var previewBytes int
// receiving the body bites to determine the preview bytes depending on the request ICAP method
if r.Method == MethodREQMOD {
if r.HTTPRequest == nil {
return nil
}
if r.HTTPRequest.Body != nil {
b, err := io.ReadAll(r.HTTPRequest.Body)
if err != nil {
return err
}
bodyBytes = b
defer func() {
err = errors.Join(err, r.HTTPRequest.Body.Close())
}()
}
}
if r.Method == MethodRESPMOD {
if r.HTTPResponse == nil {
return nil
}
if r.HTTPResponse.Body != nil {
b, err := io.ReadAll(r.HTTPResponse.Body)
if err != nil {
return err
}
bodyBytes = b
defer func() {
err = errors.Join(err, r.HTTPResponse.Body.Close())
}()
}
}
previewBytes = len(bodyBytes)
// if the preview byte is 0 or less, there is no question of the body-fitting insides
if previewBytes > 0 {
r.bodyFittedInPreview = true
}
// if the preview bytes are greater than what was mentioned by the ICAP Server (did not fit in the body)
if previewBytes > maxBytes {
previewBytes = maxBytes
r.bodyFittedInPreview = false
// storing the rest of the body byte which was not sent as preview for further operations
r.remainingPreviewBytes = bodyBytes[maxBytes:]
}
// set the body to the http message depending on the request method
if r.Method == MethodREQMOD {
r.HTTPRequest.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
if r.Method == MethodRESPMOD {
r.HTTPResponse.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
// assign preview byte information to the header
r.Header.Set("Preview", strconv.Itoa(previewBytes))
r.PreviewBytes = previewBytes
r.previewSet = true
return err
}
// to the ICAP server to ensure all required headers are set.
func (r *Request) setDefaultRequestHeaders() {
if _, exists := r.Header["Allow"]; !exists {
r.Header.Add("Allow", "204") // assigning 204 by default if Allow not provided
}
if _, exists := r.Header["Host"]; !exists {
hostName, _ := os.Hostname()
r.Header.Add("Host", hostName)
}
}
// extendHeader extends the current ICAP Request header with a new header.
func (r *Request) extendHeader(hdr http.Header) error {
for header, values := range hdr {
if header == previewHeader && r.previewSet {
continue
}
if header == encapsulatedHeader {
continue
}
for _, value := range values {
if header == previewHeader {
pb, err := strconv.Atoi(value)
if err != nil {
return err
}
err = r.SetPreview(pb)
if err != nil {
return err
}
continue
}
r.Header.Add(header, value)
}
}
return nil
}
// validate checks if the ICAP request is valid or not.
func (r *Request) validate() error {
var err error
// check if the ICAP request has a context
if r.ctx == nil {
err = errors.Join(err, ErrNoContext)
}
// check if the ICAP request method is allowed
if methodAllowed := slices.Contains([]string{
MethodOPTIONS,
MethodRESPMOD,
MethodREQMOD,
}, r.Method); !methodAllowed {
err = errors.Join(err, ErrMethodNotAllowed)
}
// check if the ICAP url is valid and contains all required fields
{
if r.URL.Scheme != schemeICAP {
err = errors.Join(err, ErrInvalidScheme)
}
if r.URL.Host == "" {
err = errors.Join(err, ErrInvalidHost)
}
}
// check if the ICAP request method is aligned with the http messages
{
if r.Method == MethodREQMOD && r.HTTPRequest == nil {
err = errors.Join(err, ErrREQMODWithoutReq)
}
if r.Method == MethodREQMOD && r.HTTPResponse != nil {
err = errors.Join(err, ErrREQMODWithResp)
}
if r.Method == MethodRESPMOD && r.HTTPResponse == nil {
err = errors.Join(err, ErrRESPMODWithoutResp)
}
}
return err
}
+146
View File
@@ -0,0 +1,146 @@
package icapclient
import (
"bytes"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/egirna/icap"
)
var (
stop = make(chan os.Signal, 1)
port = 1344
)
const (
previewBytes = 24
goodFileDetectStr = "GOOD FILE"
badFileDetectStr = "BAD FILE"
goodURL = "http://goodifle.com"
badURL = "http://badfile.com"
)
func startTestServer() {
icap.HandleFunc("/respmod", respmodHandler)
icap.HandleFunc("/reqmod", reqmodHandler)
log.Println("Starting ICAP test server...")
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
go func() {
if err := icap.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil {
log.Println("Failed to start ICAP test server: ", err.Error())
return
}
}()
time.Sleep(5 * time.Millisecond)
log.Printf("ICAP test server is running on localhost:%d\n...\n", port)
<-stop
log.Println("ICAP test server is shut down!")
}
func stopTestServer() {
stop <- syscall.SIGKILL
}
func respmodHandler(w icap.ResponseWriter, req *icap.Request) {
h := w.Header()
h.Set("ISTag", "ICAP-TEST")
h.Set("Service", "ICAP-TEST-SERVICE")
switch req.Method {
case "OPTIONS":
h.Set("Methods", "RESPMOD")
h.Set("Allow", "204")
if previewBytes > 0 {
h.Set("Preview", strconv.Itoa(previewBytes))
}
h.Set("Transfer-Preview", "*")
w.WriteHeader(http.StatusOK, nil, false)
case "RESPMOD":
defer req.Response.Body.Close()
if val, exist := req.Header["Allow"]; !exist || (len(val) > 0 && val[0] != "204") {
w.WriteHeader(http.StatusNoContent, nil, false)
return
}
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, req.Response.Body); err != nil {
log.Println("Failed to copy the response body to buffer: ", err.Error())
w.WriteHeader(http.StatusNoContent, nil, false)
return
}
status := 0
if strings.Contains(buf.String(), goodFileDetectStr) {
status = http.StatusNoContent
}
if strings.Contains(buf.String(), badFileDetectStr) {
status = http.StatusOK
}
w.WriteHeader(status, nil, false)
}
}
func reqmodHandler(w icap.ResponseWriter, req *icap.Request) {
h := w.Header()
h.Set("ISTag", "ICAP-TEST")
h.Set("Service", "ICAP-TEST-SERVICE")
switch req.Method {
case "OPTIONS":
h.Set("Methods", "REQMOD")
h.Set("Allow", "204")
if previewBytes > 0 {
h.Set("Preview", strconv.Itoa(previewBytes))
}
h.Set("Transfer-Preview", "*")
w.WriteHeader(http.StatusOK, nil, false)
case "REQMOD":
if val, exist := req.Header["Allow"]; !exist || (len(val) > 0 && val[0] != "204") {
w.WriteHeader(http.StatusNoContent, nil, false)
return
}
fileURL := req.Request.RequestURI
status := 0
if fileURL == goodURL {
status = http.StatusNoContent
}
if fileURL == badURL {
status = http.StatusOK
}
w.WriteHeader(status, nil, false)
}
}
func testServerRunning() bool {
lstnr, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return true
}
lstnr.Close()
return false
}