diff --git a/Makefile b/Makefile index c478ccc..c5763c4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ FLAGS = -ldflags "\ " test: - go test -cover ./... + go test ./... run: STATICS=statics/www/ go run $(FLAGS) ./cmd/inceptiondb/... diff --git a/api/0_build.go b/api/0_build.go index d238bba..961a05a 100644 --- a/api/0_build.go +++ b/api/0_build.go @@ -12,13 +12,19 @@ import ( "github.com/fulldump/inceptiondb/statics" ) -func Build(s service.Servicer, staticsDir, version string) *box.B { // TODO: remove datadir +func Build(s service.Servicer, staticsDir, version, apiKey, apiSecret string, hideUI bool) *box.B { // TODO: remove datadir b := box.NewBox() v1 := b.Resource("/v1") v1.WithInterceptors(box.SetResponseHeader("Content-Type", "application/json")) + if apiKey != "" && apiSecret != "" { + v1.WithInterceptors( + Authenticate(apiKey, apiSecret), + ) + } + apicollectionv1.BuildV1Collection(v1, s). WithInterceptors( injectServicer(s), @@ -58,11 +64,13 @@ func Build(s service.Servicer, staticsDir, version string) *box.B { // TODO: rem return spec }) - // Mount statics - b.Resource("/*"). - WithActions( - box.Get(statics.ServeStatics(staticsDir)).WithName("serveStatics"), - ) + if !hideUI { + // Mount statics + b.Resource("/*"). + WithActions( + box.Get(statics.ServeStatics(staticsDir)).WithName("serveStatics"), + ) + } return b } diff --git a/api/0_helpers.go b/api/0_helpers.go index a313a0f..b4dad9c 100644 --- a/api/0_helpers.go +++ b/api/0_helpers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "github.com/fulldump/box" @@ -50,6 +51,10 @@ func (p PrettyError) MarshalJSON() ([]byte, error) { }) } +func (p PrettyError) MarshalTo(w io.Writer) error { + return json.NewEncoder(w).Encode(p) +} + func InterceptorUnavailable(db *database.Database) box.I { return func(next box.H) box.H { return func(ctx context.Context) { @@ -79,6 +84,17 @@ func PrettyErrorInterceptor(next box.H) box.H { } w := box.GetResponse(ctx) + if err == ErrUnauthorized { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]interface{}{ + "message": err.Error(), + "description": fmt.Sprintf("user is not authenticated"), + }, + }) + return + } + if err == box.ErrResourceNotFound { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/api/0_interceptors.go b/api/0_interceptors.go index 9a9fb7c..c14d2fb 100644 --- a/api/0_interceptors.go +++ b/api/0_interceptors.go @@ -2,6 +2,7 @@ package api import ( "context" + "errors" "fmt" "log" "net/http" @@ -12,6 +13,30 @@ import ( "github.com/fulldump/box" ) +var ErrUnauthorized = errors.New("unauthorized") + +func Authenticate(apiKey, apiSecret string) box.I { + return func(next box.H) box.H { + return func(ctx context.Context) { + + if apiKey == "" && apiSecret == "" { + next(ctx) + return + } + + r := box.GetRequest(ctx) + key := r.Header.Get("X-Api-Key") + secret := r.Header.Get("X-Api-Secret") + + if key != apiKey || secret != apiSecret { + box.SetError(ctx, ErrUnauthorized) + return + } + next(ctx) + } + } +} + func RecoverFromPanic(next box.H) box.H { return func(ctx context.Context) { defer func() { diff --git a/api/acceptance_test.go b/api/acceptance_test.go index 76e2311..c4a8dd0 100644 --- a/api/acceptance_test.go +++ b/api/acceptance_test.go @@ -23,7 +23,7 @@ func TestAcceptance(t *testing.T) { s := service.NewService(db) - b := Build(s, "", "test") + b := Build(s, "", "test", "", "", false) b.WithInterceptors( InterceptorUnavailable(db), RecoverFromPanic, diff --git a/api/auth_test.go b/api/auth_test.go new file mode 100644 index 0000000..da09803 --- /dev/null +++ b/api/auth_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/fulldump/apitest" + "github.com/fulldump/biff" + + "github.com/fulldump/inceptiondb/database" + "github.com/fulldump/inceptiondb/service" +) + +func TestAuthentication(t *testing.T) { + + biff.Alternative("Authentication", func(a *biff.A) { + + db := database.NewDatabase(&database.Config{ + Dir: t.TempDir(), + }) + + s := service.NewService(db) + + apiKey := "my-key" + apiSecret := "my-secret" + + b := Build(s, "", "test", apiKey, apiSecret, false) + b.WithInterceptors( + PrettyErrorInterceptor, + ) + + api := apitest.NewWithHandler(b) + + a.Alternative("Missing headers", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections").Do() + biff.AssertEqual(resp.StatusCode, http.StatusUnauthorized) + biff.AssertEqualJson(resp.BodyJson(), map[string]any{ + "error": map[string]any{ + "message": "unauthorized", + "description": "user is not authenticated", + }, + }) + }) + + a.Alternative("Wrong Key", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections"). + WithHeader("X-Api-Key", "wrong-key"). + WithHeader("X-Api-Secret", apiSecret). + Do() + biff.AssertEqual(resp.StatusCode, http.StatusUnauthorized) + }) + + a.Alternative("Wrong Secret", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections"). + WithHeader("X-Api-Key", apiKey). + WithHeader("X-Api-Secret", "wrong-secret"). + Do() + biff.AssertEqual(resp.StatusCode, http.StatusUnauthorized) + }) + + a.Alternative("Correct credentials", func(a *biff.A) { + resp := api.Request("GET", "/v1/collections"). + WithHeader("X-Api-Key", apiKey). + WithHeader("X-Api-Secret", apiSecret). + Do() + biff.AssertEqual(resp.StatusCode, http.StatusOK) + }) + + }) +} diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 3dbd859..b9fe9da 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -28,7 +28,8 @@ func Bootstrap(c *configuration.Configuration) (start, stop func()) { Dir: c.Dir, }) - b := api.Build(service.NewService(db), c.Statics, VERSION) + // b := api.Build(service.NewService(db), c.Statics, VERSION) + b := api.Build(service.NewService(db), c.Statics, VERSION, c.ApiKey, c.ApiSecret, c.HideUI) if c.EnableCompression { b.WithInterceptors(api.Compression) } diff --git a/cmd/inceptiondb/main.go b/cmd/inceptiondb/main.go index 5119084..53a0489 100644 --- a/cmd/inceptiondb/main.go +++ b/cmd/inceptiondb/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "log" "os" "github.com/fulldump/goconfig" @@ -42,6 +43,10 @@ func main() { e.Encode(c) } + if c.ApiKey == "" || c.ApiSecret == "" { + log.Println("ApiKey and ApiSecret are not set, authentication will be disabled") + } + start, _ := bootstrap.Bootstrap(c) start() } diff --git a/configuration/configuration.go b/configuration/configuration.go index 9032ca9..503b16a 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -10,4 +10,7 @@ type Configuration struct { ShowBanner bool `usage:"show big banner"` ShowConfig bool `usage:"print config"` EnableCompression bool `usage:"enable http compression (gzip)"` + ApiKey string `usage:"API Key for v2 authentication"` + ApiSecret string `usage:"API Secret for v2 authentication"` + HideUI bool `usage:"do not serve UI"` } diff --git a/statics/www/index.html b/statics/www/index.html index eb989ae..ff50e31 100644 --- a/statics/www/index.html +++ b/statics/www/index.html @@ -1,24 +1,29 @@ + InceptionDB Console - + - + - + - + - - + + + -
- -
-
-
-

{{ selectedActivityEntry.label || 'Request details' }}

-

- {{ selectedActivityEntry.method }} -   - {{ selectedActivityEntry.url }} -  (Status {{ selectedActivityEntry.statusCode }}) -

+
+ +
+
+
+

{{ + selectedActivityEntry.label || 'Request details' }}

+

+ {{ selectedActivityEntry.method + }} +   + {{ selectedActivityEntry.url }} +  (Status {{ selectedActivityEntry.statusCode }}) +

+
-
-
-

Request headers

-
    -
  • - {{ header.key }}: - {{ header.value }} -
  • -
-
-
-

Request body

-
{{ activityRequestBody(selectedActivityEntry) }}
-
-
-

Request formats

-
-
-
+
+

Request body

+
{{ activityRequestBody(selectedActivityEntry) }}
+
+
+

Request formats

+
+
+ + : 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/40'" @click="selectedActivityRequestTab = tab.id"> + {{ tab.label }} + +
+
{{ activeActivityRequestTabContent }}
-
{{ activeActivityRequestTabContent }}
+
+
+ Started {{ formatActivityTime(selectedActivityEntry.startedAt) }} + Duration {{ + formatDuration(selectedActivityEntry.durationMs) }} +
+
+ +
-
- Started {{ formatActivityTime(selectedActivityEntry.startedAt) }} - Duration {{ formatDuration(selectedActivityEntry.durationMs) }} -
-
- - -
-
-
-
-
+ + +
+
+

Authentication Required

+

This database requires an API Key and API Secret to continue.

-
-

API status

-
- - - {{ connectionStatusLabel }} - - + +
+
+ +
-

- {{ connectionStatusDescription }} -

-
- -
-
- - +
+ +
-
+ +
+ -

{{ createForm.error }}

- -
-
-
Loading collections…
-
{{ collectionsError }}
- -
- - -
-
-

Welcome to InceptionDB

-

Select or create a collection in the sidebar to start querying, inserting, or deleting documents.

+
- -
-
-
-

Collection {{ selectedCollection.name }}

-

- Total: {{ selectedCollection.total }} - Last query: {{ queryStats.elapsed }} - Documents listed: {{ queryStats.returned }} +

+ +
+ + +
+
+

Welcome to InceptionDB

+

Select or create a collection in the sidebar to start querying, + inserting, or deleting documents.

+
-
- - +
+
+
+

Collection {{ selectedCollection.name }} +

+

+ Total: {{ selectedCollection.total + }} + Last query: {{ queryStats.elapsed }} + Documents listed: {{ queryStats.returned }} +

+
+
+ + +
+
+ +
+
+
+

Quick document lookup

+

Find a document by ID without changing your current + filters.

- -
- - +
+ + + +
+
+

{{ quickSearch.error }}

+
+
+
+

Document {{ quickSearch.result.id }} +

+

Source: {{ + quickSearchSourceLabel }}

+
+
+ +
+
{{ quickSearchResultText }}
+
+

+ No document with ID "{{ quickSearch.lastCheckedId }}" was found. +

+
-
+
+
+
- From (inclusive) -
- -
+ + +

{{ filterError }}

+
- To (exclusive) -
- -
+ +
- -
-
+
+ + +
-
-
-
+
-

- Results - -  🛈 - -

-
-
-
- - + From + (inclusive) +
+
-
- +
+
+ To + (exclusive) +
+
-

{{ exportState.error }}

-

{{ exportState.progress }}

+
+
-
-
Running query…
-
{{ queryError }}
-
No documents found.
-
-
-
- -
- skip - + +
+
+
+
+

+ Results + +  🛈 + +

+
+
+
+ +
-
- limit - +
+
- +

{{ + exportState.error }}

+

{{ + exportState.progress }}

-

{{ pageInfo }}

-
-
-
-
-

Document #{{ offset + idx + 1 }}

-

ID: {{ documentId(row) }}

+
+
+
Running query…
+
{{ queryError }}
+
No documents + found.
+
+
+
+ +
+ skip +
-
- - +
+ limit +
+
-
- - +

{{ pageInfo }}

+
+
+
+
+
+

Document #{{ + offset + idx + 1 }}

+

+ ID: {{ documentId(row) }}

+
+
+ + +
+
+
+ + +

+ Set a field to null to remove it. +

+
+ + +
+

+ {{ editingDocuments[documentId(row)].error }} +

+

+ {{ editingDocuments[documentId(row)].success }} +

+
+
{{ formatDocument(row) }}
+

+ The document must include an "id" field to enable editing or + deletion. +

+
+
+
+
+ + + + + + + + + + + + + + + +
# + + {{ column }} + + Actions
{{ + offset + idx + 1 }} + {{ + formatTableValue(row, column) }} + +
+ + + +
+
+
+
+
+

+ Edit document {{ editing.id }}

+

Document #{{ editing.position + }}

+
+

- Set a field to null to remove it. + Set a field to null to + remove it.

- -
-

- {{ editingDocuments[documentId(row)].error }} +

+ {{ editing.state.error }}

-

- {{ editingDocuments[documentId(row)].success }} +

+ {{ editing.state.success }}

-
{{ formatDocument(row) }}
-

- The document must include an "id" field to enable editing or deletion. -

-
-
-
- - - - - - - - - - - - - - - -
# - {{ column }} - Actions
{{ offset + idx + 1 }} - {{ formatTableValue(row, column) }} - -
- - - -
-
-
-
-
-

Edit document {{ editing.id }}

-

Document #{{ editing.position }}

-
- -

- Set a field to null to remove it. -

-
- -
-

- {{ editing.state.error }} -

-

- {{ editing.state.success }} -

-
-
-
-
- -
- skip - -
-
- limit - -
- +

{{ pageInfo }}

-

{{ pageInfo }}

-
-
-
- -
-
-

Estimated storage usage returned by the size endpoint.

- -
-

- Select a collection to inspect its metrics. -

-

Loading metrics…

-

{{ sizeMetrics.error }}

-
-

- Updated at {{ sizeMetricsUpdatedLabel }} +

+
+ +
+
+

Estimated storage usage returned by the size + endpoint.

+ +
+

+ Select a collection to inspect its metrics.

-
-
-
{{ entry.label }}
-
{{ entry.value }}
-
-
-
-

No metrics reported for this collection.

-

Metrics will appear after refreshing.

-
-
- -
- -
-
-

- Documents missing fields will receive these values when inserted. +

Loading metrics…

- -
-
-

Special values

-
    -
  • - uuid() - Generate a unique identifier. -
  • -
  • - unixnano() - Insert the current timestamp in nanoseconds. -
  • -
  • - auto() - Assign an auto-incrementing number within the collection. -
  • -
-
- -
- - -
-

{{ defaultsForm.error }}

-

{{ defaultsForm.success }}

-
-
- -
- -
- -
- +

{{ sizeMetrics.error + }}

+
+

+ Updated at {{ sizeMetricsUpdatedLabel }} +

+
+
+
{{ entry.label }}
+
{{ + entry.value }}
+
+
+
+

No metrics reported + for this collection.

+

Metrics will appear after refreshing.

-

{{ insertForm.error }}

-

{{ insertForm.success }}

-
-
- -
-

- Upload a CSV file to insert multiple documents at once. The first row should contain the field names. -

-
-
- - +
+ +
+
+

+ Documents missing fields will receive these values when inserted. +

+
-
- - +
+

Special + values

+
    +
  • + uuid() + Generate a unique identifier. +
  • +
  • + unixnano() + Insert the current timestamp in nanoseconds. +
  • +
  • + auto() + Assign an auto-incrementing number within the collection. +
  • +
-
- - + +
+ +
+

{{ defaultsForm.error }} +

+

{{ + defaultsForm.success }}

-

Selected file: {{ csvImportForm.fileName }}

-
- - -
-

{{ csvImportForm.error }}

-

{{ csvImportForm.success }}

-

{{ csvImportForm.progress }}

-
-
- -
-
- -
-

{{ indexMessages.error }}

-

{{ indexMessages.success }}

-
-
- - -
-
- - +
+ +
+ +
+
-
+

{{ insertForm.error }}

+

{{ + insertForm.success }}

+
+
+ +
+ +
+

+ Upload a CSV file to insert multiple documents at once. The first row should + contain the field names. +

+
+
+ + +
- - + +
- -
-
- - -

- Separate fields with commas. Prefix with "-" for descending order. -

+ +
- -
+

Selected file: {{ + csvImportForm.fileName }}

- -
- -
Loading indexes…
-
This collection has no indexes yet.
-
    -
  • -
    -
    -
    - {{ index.name }} - - {{ index.type === 'btree' ? 'B-Tree' : 'Map' }} - -
    -

    Field: {{ index.field }}

    -

    Fields: {{ Array.isArray(index.fields) ? index.fields.join(', ') : '' }}

    -
    - Unique - Sparse -
    +

    {{ csvImportForm.error + }}

    +

    {{ + csvImportForm.success }}

    +

    {{ + csvImportForm.progress }}

    +
    +
    + +
    + +
    +
    + +
    +

    {{ indexMessages.error + }}

    +

    {{ + indexMessages.success }}

    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    -
    +
    +
    + + +

    + Separate fields with commas. Prefix with "-" for descending order. +

    +
    + + +
    +
    + +
    -
  • -
+ +
Loading indexes…
+
This collection + has no indexes yet.
+
    +
  • +
    +
    +
    + {{ index.name }} + + {{ index.type === 'btree' ? 'B-Tree' : 'Map' }} + +
    +

    Field: + {{ index.field }}

    +

    Fields: {{ + Array.isArray(index.fields) ? index.fields.join(', ') : '' }} +

    +
    + Unique + Sparse +
    +
    + +
    +
  • +
+
-
-
-
-
-
-
+
- - - - + return { + // state + collections, + collectionsLoading, + collectionsError, + selectedCollection, + selectedCollectionName, + indexes, + indexesLoading, + filterText, + filterError, + queryRows, + queryLoading, + queryError, + queryStats, + resultsViewMode, + selectedIndexName, + activeIndex, + mapValue, + reverse, + rangeFrom, + rangeTo, + editingDocuments, + tableColumns, + openEditingRows, + indexForm, + indexMessages, + insertForm, + csvImportForm, + defaultsForm, + defaultsHelpOpen, + collapsibleCards, + createForm, + connectionStatus, + activityLog, + activityDetailOpen, + selectedActivityEntry, + exportState, + sizeMetrics, + quickSearch, + quickSearchResultText, + quickSearchSourceLabel, + sizeMetricsEntries, + sizeMetricsUpdatedLabel, + connectionStatusLabel, + connectionStatusDescription, + connectionStatusBadgeClass, + connectionStatusDotClass, + connectionStatusButtonLabel, + connectionStatusChecking, + disablePrev, + disableNext, + skip, + limit, + offset, + pageInfo, + selectResultsView, + // methods + prettyTotal, + isSelected, + toggleCreateForm, + toggleCard, + toggleIndexForm, + selectCollection, + runQuery, + lookupDocument, + applyQuickSearchResult, + exportResults, + nextPage, + prevPage, + commitSkip, + commitLimit, + documentId, + canEditDocument, + isEditingRow, + openEditRow, + closeEditRow, + saveEditRow, + onEditDraftInput, + createIndex, + removeIndex, + resetDefaultsForm, + saveDefaults, + insertDocument, + onCsvFileChange, + resetCsvImportForm, + importCsv, + createCollection, + dropCollection, + deleteRow, + canDeleteRow, + formatDocument, + formatTableValue, + refreshConnectionStatus, + refreshSizeMetrics, + formatActivityTime, + formatDuration, + activityStatusLabel, + activityStatusBadgeClass, + clearActivityLog, + activityMarkerClass, + activityMarkerTitle, + activityRequestHeaders, + activityRequestBody, + activityRequestTabEntries, + selectedActivityRequestTab, + activeActivityRequestTabContent, + buildCurlCommand, + activityModal, + modalMode, + openActivityDetail, + closeActivityDetail, + closeActivityModal, + authModal, + authForm, + openAuthModal, + closeAuthModal, + saveAuth, + }; + }, + }).mount('#app'); + + - + + \ No newline at end of file