Skip to content

Commit 7da5be9

Browse files
feat: uploads sync (#30)
fix #27
1 parent add1166 commit 7da5be9

29 files changed

Lines changed: 1835 additions & 264 deletions

crypto.go

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -73,65 +73,36 @@ func deriveProfileKey(passphrase string, salt []byte) []byte {
7373
)
7474
}
7575

76-
func encryptBytes(key []byte, plaintext []byte) (string, error) {
76+
func encodeToBase64(key []byte, plaintext []byte) (string, error) {
7777
// XChaCha20-Poly1305 requires a unique nonce for each encryption.
7878
// The nonce is not secret, so we store it next to the ciphertext.
7979
// Stored format:
8080
// base64(nonce || ciphertext)
81-
aead, err := chacha20poly1305.NewX(key)
82-
if err != nil {
83-
return "", fmt.Errorf("Create cipher: %w", err)
84-
}
85-
86-
nonce, err := randomBytes(aead.NonceSize())
81+
payload, err := encryptRawBytes(key, plaintext)
8782
if err != nil {
8883
return "", err
8984
}
9085

91-
ciphertext := aead.Seal(nil, nonce, plaintext, nil)
92-
93-
payload := make([]byte, 0, len(nonce)+len(ciphertext))
94-
payload = append(payload, nonce...)
95-
payload = append(payload, ciphertext...)
96-
9786
return base64Encoding.EncodeToString(payload), nil
9887
}
9988

100-
func decryptBytes(key []byte, encoded string) ([]byte, error) {
89+
func decodeFromBase64(key []byte, encoded string) ([]byte, error) {
10190
// The encrypted payload is stored as base64(nonce || ciphertext).
10291
// Split the nonce back out before opening the ciphertext.
10392
payload, err := base64Encoding.DecodeString(encoded)
10493
if err != nil {
10594
return nil, fmt.Errorf("Decode ciphertext: %w", err)
10695
}
10796

108-
aead, err := chacha20poly1305.NewX(key)
109-
if err != nil {
110-
return nil, fmt.Errorf("Create cipher: %w", err)
111-
}
112-
113-
nonceSize := aead.NonceSize()
114-
if len(payload) < nonceSize {
115-
return nil, fmt.Errorf("Ciphertext is too short")
116-
}
117-
118-
nonce := payload[:nonceSize]
119-
ciphertext := payload[nonceSize:]
120-
121-
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
122-
if err != nil {
123-
return nil, fmt.Errorf("Decrypt: %w", err)
124-
}
125-
126-
return plaintext, nil
97+
return decryptRawBytes(key, payload)
12798
}
12899

129100
func encryptString(key []byte, plaintext string) (string, error) {
130-
return encryptBytes(key, []byte(plaintext))
101+
return encodeToBase64(key, []byte(plaintext))
131102
}
132103

133104
func decryptString(key []byte, encoded string) (string, error) {
134-
plaintext, err := decryptBytes(key, encoded)
105+
plaintext, err := decodeFromBase64(key, encoded)
135106
if err != nil {
136107
return "", err
137108
}
@@ -192,3 +163,46 @@ func unlockProfileCryptoParams(profile *ProfileEntry, passphrase string) ([]byte
192163

193164
return key, nil
194165
}
166+
167+
// encryptRawBytes encrypts binary data with chacha20poly1305
168+
func encryptRawBytes(key []byte, plaintext []byte) ([]byte, error) {
169+
aead, err := chacha20poly1305.NewX(key)
170+
if err != nil {
171+
return nil, fmt.Errorf("Create cipher: %w", err)
172+
}
173+
174+
nonce, err := randomBytes(aead.NonceSize())
175+
if err != nil {
176+
return nil, err
177+
}
178+
179+
ciphertext := aead.Seal(nil, nonce, plaintext, nil)
180+
181+
payload := make([]byte, 0, len(nonce)+len(ciphertext))
182+
payload = append(payload, nonce...)
183+
payload = append(payload, ciphertext...)
184+
185+
return payload, nil
186+
}
187+
188+
func decryptRawBytes(key []byte, payload []byte) ([]byte, error) {
189+
aead, err := chacha20poly1305.NewX(key)
190+
if err != nil {
191+
return nil, fmt.Errorf("Create cipher: %w", err)
192+
}
193+
194+
nonceSize := aead.NonceSize()
195+
if len(payload) < nonceSize {
196+
return nil, fmt.Errorf("Ciphertext is too short")
197+
}
198+
199+
nonce := payload[:nonceSize]
200+
ciphertext := payload[nonceSize:]
201+
202+
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
203+
if err != nil {
204+
return nil, fmt.Errorf("Decrypt: %w", err)
205+
}
206+
207+
return plaintext, nil
208+
}

elabftw_client.go

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"crypto/tls"
1515
"database/sql"
1616
"encoding/json"
17+
"errors"
1718
"fmt"
1819
"io"
1920
"net/http"
@@ -32,6 +33,12 @@ type ElabftwInfo struct {
3233
Raw map[string]any `json:"raw"`
3334
}
3435

36+
type elabftwErrorResponse struct {
37+
Code int `json:"code"`
38+
Message string `json:"message"`
39+
Description string `json:"description"`
40+
}
41+
3542
func (a *App) loadElabftwClientConfig(profileUUID string, instanceID int64) (*elabftwClientConfig, error) {
3643
profileUUID, err := a.requireUnlockedProfile(profileUUID)
3744
if err != nil {
@@ -91,18 +98,24 @@ func (a *App) loadElabftwClientConfig(profileUUID string, instanceID int64) (*el
9198
return &cfg, nil
9299
}
93100

94-
func elabftwHTTPClient(verifyTLS bool) *http.Client {
101+
func elabftwHTTPClient(verifyTLS bool, longTimeout bool) *http.Client {
95102
transport := &http.Transport{
96103
TLSClientConfig: &tls.Config{
97104
// Only false when the user explicitly disables TLS verification.
98105
InsecureSkipVerify: !verifyTLS,
99106
},
100107
}
101108

102-
// timeout prevents the desktop app from hanging forever if the server is unreachable
103-
// Transport carries our TLS configuration, including whether to verify certificates
109+
// regular API requests should fail quickly if the server is unreachable
110+
// but Uploads get a much longer timeout because the deadline covers the entire
111+
// request, including sending the file, which may take several minutes on
112+
// slower connections
113+
timeout := 30 * time.Second // 30 sec
114+
if longTimeout {
115+
timeout = 10 * time.Minute // 10 mins
116+
}
104117
return &http.Client{
105-
Timeout: 30 * time.Second,
118+
Timeout: timeout,
106119
Transport: transport,
107120
}
108121
}
@@ -111,18 +124,17 @@ func (a *App) elabftwRequest(
111124
profileUUID string,
112125
instanceID int64,
113126
method string,
114-
apiPath string,
127+
path string,
115128
body io.Reader,
129+
isUpload bool,
130+
headers ...map[string]string,
116131
) (*http.Response, error) {
117132
cfg, err := a.loadElabftwClientConfig(profileUUID, instanceID)
118133
if err != nil {
119134
return nil, err
120135
}
121136

122-
apiPath = "/" + strings.TrimLeft(apiPath, "/")
123-
url := elabftwAPIBaseURL(cfg.SiteURL) + apiPath
124-
125-
req, err := http.NewRequest(method, url, body)
137+
req, err := http.NewRequest(method, elabftwAPIBaseURL(cfg.SiteURL)+path, body)
126138
if err != nil {
127139
return nil, fmt.Errorf("create elabftw request: %w", err)
128140
}
@@ -134,9 +146,17 @@ func (a *App) elabftwRequest(
134146
req.Header.Set("Content-Type", "application/json")
135147
}
136148

137-
resp, err := elabftwHTTPClient(cfg.VerifyTLS).Do(req)
149+
for _, h := range headers {
150+
for k, v := range h {
151+
req.Header.Set(k, v)
152+
}
153+
}
154+
155+
client := elabftwHTTPClient(cfg.VerifyTLS, isUpload)
156+
157+
resp, err := client.Do(req)
138158
if err != nil {
139-
return nil, fmt.Errorf("call elabftw %s %s: %w", method, apiPath, err)
159+
return nil, fmt.Errorf("call elabftw %s %s: %w", method, path, err)
140160
}
141161

142162
return resp, nil
@@ -156,11 +176,26 @@ func decodeElabftwJSONResponse(resp *http.Response, target any) error {
156176

157177
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
158178
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
179+
180+
var apiErr elabftwErrorResponse
181+
if err := json.Unmarshal(body, &apiErr); err == nil {
182+
// Prefer a detailed description if available.
183+
if apiErr.Description != "" {
184+
return errors.New(apiErr.Description)
185+
}
186+
187+
if apiErr.Message != "" {
188+
return errors.New(apiErr.Message)
189+
}
190+
}
191+
192+
// Fallback if the response isn't JSON.
159193
msg := strings.TrimSpace(string(body))
160-
if msg == "" {
161-
return fmt.Errorf("elabftw returned HTTP %d", resp.StatusCode)
194+
if msg != "" {
195+
return errors.New(msg)
162196
}
163-
return fmt.Errorf("elabftw returned HTTP %d: %s", resp.StatusCode, msg)
197+
198+
return fmt.Errorf("eLabFTW returned HTTP %d", resp.StatusCode)
164199
}
165200

166201
if target == nil {
@@ -184,7 +219,7 @@ func jsonBody(v any) (*bytes.Reader, error) {
184219

185220
/* ---------- INFO ENDPOINT ---------- */
186221
func (a *App) FetchElabftwInfo(profileUUID string, instanceID int64) (*ElabftwInfo, error) {
187-
resp, err := a.elabftwRequest(profileUUID, instanceID, http.MethodGet, "/info", nil)
222+
resp, err := a.elabftwRequest(profileUUID, instanceID, http.MethodGet, "/info", nil, false)
188223
if err != nil {
189224
return nil, err
190225
}

frontend/eslint.config.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,12 @@ export default [
2222
},
2323

2424
{
25-
files: ['**/*.svelte'],
25+
files: ['**/*.svelte', '**/*.svelte.ts'],
2626
languageOptions: {
2727
parser: svelteParser,
2828
parserOptions: {
2929
parser: ts.parser,
3030
},
31-
// rules: {
32-
// ...svelte.configs.recommended.rules,
33-
// 'no-unused-vars': 'warn',
34-
// },
3531
},
3632
},
3733
];

frontend/src/App.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ SPDX-License-Identifier: GPL-3.0-or-later
1212
import ProfileSelector from './components/ProfileSelector/ProfileSelector.svelte';
1313
import MainApp from './components/MainApp.svelte';
1414
import logo from './assets/images/elabftw-logo-white-800px.png';
15+
import Alert from './components/Alert.svelte';
1516
1617
let appState = $state('select-profile');
1718
let activeProfile = $state(null);
1819
let activeProfileName = $state(null);
1920
</script>
2021

2122
<main>
23+
<Alert />
2224
{#if appState === 'select-profile'}
2325
<img alt='eLabFTW logo' id='logo' src={logo} style='width: 200px;' />
2426
<ProfileSelector

frontend/src/components/Alert.svelte

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
99
-->
1010

1111
<script lang='ts'>
12-
export type AlertState = {
13-
type: 'success' | 'error' | 'warning' | 'info';
14-
message: string;
15-
};
12+
import { alert, showAlert } from "./stores/alert.svelte";
1613
17-
type AlertProps = Partial<AlertState>;
18-
19-
let { type = 'success', message = '' }: AlertProps = $props();
20-
let visible = $state(true);
2114
let closing = $state(false);
2215
2316
/* allow having the Alert at the top left of every component (not depend of its parent) */
@@ -28,12 +21,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
2821
};
2922
}
3023
31-
// Re-show the alert when the parent provides a new message.
3224
$effect(() => {
33-
if (message) {
34-
visible = true;
35-
closing = false;
36-
}
25+
if (alert.current) closing = false;
3726
});
3827
3928
function close() {
@@ -42,19 +31,19 @@ SPDX-License-Identifier: GPL-3.0-or-later
4231
4332
function onAnimationEnd() {
4433
if (closing) {
45-
visible = false;
4634
closing = false;
35+
showAlert(null);
4736
}
4837
}
4938
</script>
5039

51-
{#if message && visible}
40+
{#if alert.current}
5241
<div
5342
use:portal
54-
class={`alert alert-${type} alert-floating ${closing ? 'alert-closing' : ''}`}
43+
class={`alert alert-${alert.current.type} alert-floating ${closing ? 'alert-closing' : ''}`}
5544
onanimationend={onAnimationEnd}
5645
>
57-
<strong>{message}</strong>
46+
<strong>{alert.current.message}</strong>
5847
<button class='alert-close' type='button' aria-label='Close alert' onclick={close}>&#x2717;</button>
5948
</div>
6049
{/if}

0 commit comments

Comments
 (0)