1package probeservices
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"reflect"
8	"sync"
9
10	"github.com/ooni/probe-cli/v3/internal/engine/model"
11)
12
13const (
14	// DefaultDataFormatVersion is the default data format version.
15	//
16	// See https://github.com/ooni/spec/tree/master/data-formats#history.
17	DefaultDataFormatVersion = "0.2.0"
18
19	// DefaultFormat is the default format
20	DefaultFormat = "json"
21)
22
23var (
24	// ErrUnsupportedDataFormatVersion indicates that the user provided
25	// in input a data format version that we do not support.
26	ErrUnsupportedDataFormatVersion = errors.New("Unsupported data format version")
27
28	// ErrUnsupportedFormat indicates that the format is not supported.
29	ErrUnsupportedFormat = errors.New("Unsupported format")
30
31	// ErrJSONFormatNotSupported indicates that the collector we're using
32	// does not support the JSON report format.
33	ErrJSONFormatNotSupported = errors.New("JSON format not supported")
34)
35
36// ReportTemplate is the template for opening a report
37type ReportTemplate struct {
38	// DataFormatVersion is unconditionally set to DefaultDataFormatVersion
39	// and you don't need to be concerned about it.
40	DataFormatVersion string `json:"data_format_version"`
41
42	// Format is unconditionally set to `json` and you don't need
43	// to be concerned about it.
44	Format string `json:"format"`
45
46	// ProbeASN is the probe's autonomous system number (e.g. `AS1234`)
47	ProbeASN string `json:"probe_asn"`
48
49	// ProbeCC is the probe's country code (e.g. `IT`)
50	ProbeCC string `json:"probe_cc"`
51
52	// SoftwareName is the app name (e.g. `measurement-kit`)
53	SoftwareName string `json:"software_name"`
54
55	// SoftwareVersion is the app version (e.g. `0.9.1`)
56	SoftwareVersion string `json:"software_version"`
57
58	// TestName is the test name (e.g. `ndt`)
59	TestName string `json:"test_name"`
60
61	// TestStartTime contains the test start time
62	TestStartTime string `json:"test_start_time"`
63
64	// TestVersion is the test version (e.g. `1.0.1`)
65	TestVersion string `json:"test_version"`
66}
67
68// NewReportTemplate creates a new ReportTemplate from a Measurement.
69func NewReportTemplate(m *model.Measurement) ReportTemplate {
70	return ReportTemplate{
71		DataFormatVersion: DefaultDataFormatVersion,
72		Format:            DefaultFormat,
73		ProbeASN:          m.ProbeASN,
74		ProbeCC:           m.ProbeCC,
75		SoftwareName:      m.SoftwareName,
76		SoftwareVersion:   m.SoftwareVersion,
77		TestName:          m.TestName,
78		TestStartTime:     m.TestStartTime,
79		TestVersion:       m.TestVersion,
80	}
81}
82
83type collectorOpenResponse struct {
84	ID               string   `json:"report_id"`
85	SupportedFormats []string `json:"supported_formats"`
86}
87
88type reportChan struct {
89	// ID is the report ID
90	ID string
91
92	// client is the client that was used.
93	client Client
94
95	// tmpl is the template used when opening this report.
96	tmpl ReportTemplate
97}
98
99// OpenReport opens a new report.
100func (c Client) OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error) {
101	if rt.DataFormatVersion != DefaultDataFormatVersion {
102		return nil, ErrUnsupportedDataFormatVersion
103	}
104	if rt.Format != DefaultFormat {
105		return nil, ErrUnsupportedFormat
106	}
107	var cor collectorOpenResponse
108	if err := c.Client.PostJSON(ctx, "/report", rt, &cor); err != nil {
109		return nil, err
110	}
111	for _, format := range cor.SupportedFormats {
112		if format == "json" {
113			return &reportChan{ID: cor.ID, client: c, tmpl: rt}, nil
114		}
115	}
116	return nil, ErrJSONFormatNotSupported
117}
118
119type collectorUpdateRequest struct {
120	// Format is the data format
121	Format string `json:"format"`
122
123	// Content is the actual report
124	Content interface{} `json:"content"`
125}
126
127type collectorUpdateResponse struct {
128	// ID is the measurement ID
129	ID string `json:"measurement_id"`
130}
131
132// CanSubmit returns true whether the provided measurement belongs to
133// this report, false otherwise. We say that a given measurement belongs
134// to this report if its report template matches the report's one.
135func (r reportChan) CanSubmit(m *model.Measurement) bool {
136	return reflect.DeepEqual(NewReportTemplate(m), r.tmpl)
137}
138
139// SubmitMeasurement submits a measurement belonging to the report
140// to the OONI collector. On success, we will modify the measurement
141// such that it contains the report ID for which it has been
142// submitted. Otherwise, we'll set the report ID to the empty
143// string, so that you know which measurements weren't submitted.
144func (r reportChan) SubmitMeasurement(ctx context.Context, m *model.Measurement) error {
145	var updateResponse collectorUpdateResponse
146	m.ReportID = r.ID
147	err := r.client.Client.PostJSON(
148		ctx, fmt.Sprintf("/report/%s", r.ID), collectorUpdateRequest{
149			Format:  "json",
150			Content: m,
151		}, &updateResponse,
152	)
153	if err != nil {
154		m.ReportID = ""
155		return err
156	}
157	return nil
158}
159
160// ReportID returns the report ID.
161func (r reportChan) ReportID() string {
162	return r.ID
163}
164
165// ReportChannel is a channel through which one could submit measurements
166// belonging to the same report. The Report struct belongs to this interface.
167type ReportChannel interface {
168	CanSubmit(m *model.Measurement) bool
169	ReportID() string
170	SubmitMeasurement(ctx context.Context, m *model.Measurement) error
171}
172
173var _ ReportChannel = &reportChan{}
174
175// ReportOpener is any struct that is able to open a new ReportChannel. The
176// Client struct belongs to this interface.
177type ReportOpener interface {
178	OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error)
179}
180
181var _ ReportOpener = Client{}
182
183// Submitter is an abstraction allowing you to submit arbitrary measurements
184// to a given OONI backend. This implementation will take care of opening
185// reports when needed as well as of closing reports when needed. Nonetheless
186// you need to remember to call its Close method when done, because there is
187// likely an open report that has not been closed yet.
188type Submitter struct {
189	channel ReportChannel
190	logger  model.Logger
191	mu      sync.Mutex
192	opener  ReportOpener
193}
194
195// NewSubmitter creates a new Submitter instance.
196func NewSubmitter(opener ReportOpener, logger model.Logger) *Submitter {
197	return &Submitter{opener: opener, logger: logger}
198}
199
200// Submit submits the current measurement to the OONI backend created using
201// the ReportOpener passed to the constructor.
202func (sub *Submitter) Submit(ctx context.Context, m *model.Measurement) error {
203	var err error
204	sub.mu.Lock()
205	defer sub.mu.Unlock()
206	if sub.channel == nil || !sub.channel.CanSubmit(m) {
207		sub.channel, err = sub.opener.OpenReport(ctx, NewReportTemplate(m))
208		if err != nil {
209			return err
210		}
211		sub.logger.Infof("New reportID: %s", sub.channel.ReportID())
212	}
213	return sub.channel.SubmitMeasurement(ctx, m)
214}
215