Skip to content

Commit fa7e037

Browse files
committed
add filestash for file browsing of syncthing files
1 parent ca4d3c5 commit fa7e037

13 files changed

Lines changed: 565 additions & 20 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ users
77
.env.*
88
.env
99

10-
filestash
10+
/filestash

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ This project provides a simple web interface and backend for managing multiple S
4949
PORT=3001 npm start
5050
```
5151
52+
- `NEXT_PUBLIC_ENABLE_FILE_STASH` (optional):
53+
- Set this to `true` to enable the File Stash integration, allowing users to upload and manage files through the web interface.
54+
- Defaults to `true` if not set.
55+
56+
- `FILESTASH_CONTAINER_TAG` (optional):
57+
- Set this to specify the tag/version of the File Stash container image used (default: `latest`).
58+
- The container used is [`machines/filestash`](https://hub.docker.com/r/machines/filestash).
5259
5360
## Usage
5461
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { handleFilestashProxy } from "@/lib/handleFilestashProxy";
2+
3+
export const GET = handleFilestashProxy;
4+
export const POST = handleFilestashProxy;
5+
export const PUT = handleFilestashProxy;
6+
export const PATCH = handleFilestashProxy;
7+
export const DELETE = handleFilestashProxy;
8+
export const OPTIONS = handleFilestashProxy;
9+
export const HEAD = handleFilestashProxy;

app/lib/awaitFileExists.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import fs from 'fs';
2+
3+
/**
4+
* Waits for a file to exist, retrying with increasing timeouts.
5+
* @param filePath The path to the file to check.
6+
* @param waitTimesMs Array of wait times in milliseconds for each retry (default: [5000, 10000, 15000]).
7+
* @throws If the file does not exist after all retries.
8+
*/
9+
export async function waitForFileExists(filePath: string, waitTimesMs: number[] = [5000, 10000, 15000]): Promise<void> {
10+
const waitTimes = [...waitTimesMs];
11+
while (!fs.existsSync(filePath)) {
12+
if (waitTimes.length === 0) {
13+
throw new Error(`File does not exist after multiple attempts: ${filePath}`);
14+
}
15+
await new Promise(resolve => setTimeout(resolve, waitTimes.shift()));
16+
}
17+
}

app/lib/constants.shared.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Enable File Stash by default unless explicitly disabled
2+
export const enableFileStash =
3+
process.env.NEXT_PUBLIC_ENABLE_FILE_STASH === 'true'
4+
? true
5+
: process.env.NEXT_PUBLIC_ENABLE_FILE_STASH === 'false'
6+
? false
7+
: true;

app/lib/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11

2-
export const syncthingContainerTag = process.env.SYNCTHING_CONTAINER_TAG || '2.0.1';
2+
export const syncthingContainerTag = process.env.SYNCTHING_CONTAINER_TAG || '2.0.3';
3+
export const fileStashContainerTag = process.env.FILESTASH_CONTAINER_TAG || 'latest';

app/lib/filestash/crypto.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { bufferToBase64url, base64urlToBuffer } from './crypto';
2+
import { describe, it, expect } from 'vitest';
3+
import { encryptFilestashConfig, decryptFilestashConfig, generateFilestashSecretKey } from './crypto';
4+
5+
const secretKey = 'vIbdVeu77i1dtX2l';
6+
const encrypted = '6HoBc1zE4iysloguKSWk5op6kAj-j_N7loY4KfvDIltZXW6JyqVxWjvnOX3mWYGyhYIVZHe-4lOWYHLrvoPIx5nQlMQ=';
7+
const decryptedValue = '{"strategy":"password_only"}';
8+
9+
describe('Filestash crypto helpers', () => {
10+
it('decrypts a known value', () => {
11+
const decrypted = decryptFilestashConfig(secretKey, encrypted);
12+
expect(decrypted).toBe(decryptedValue);
13+
});
14+
15+
it('encrypts and decrypts round-trip', () => {
16+
const reEncrypted = encryptFilestashConfig(secretKey, decryptedValue);
17+
const roundTrip = decryptFilestashConfig(secretKey, reEncrypted);
18+
expect(roundTrip).toBe(decryptedValue);
19+
});
20+
21+
it('generates a valid secret key', () => {
22+
const key = generateFilestashSecretKey();
23+
expect(key).toMatch(/^[a-zA-Z0-9]{16}$/);
24+
});
25+
26+
it('encrypts to a different value each time', () => {
27+
const enc1 = encryptFilestashConfig(secretKey, decryptedValue);
28+
const enc2 = encryptFilestashConfig(secretKey, decryptedValue);
29+
expect(enc1).not.toBe(enc2);
30+
// Both should decrypt to the same value
31+
expect(decryptFilestashConfig(secretKey, enc1)).toBe(decryptedValue);
32+
expect(decryptFilestashConfig(secretKey, enc2)).toBe(decryptedValue);
33+
});
34+
35+
it('produces values compatible with another filestash config', () => {
36+
// Test with actual values from the config.json file
37+
const actualSecretKey = 'vIbdVeu77i1dtX2l';
38+
const actualEncryptedParams = '6HoBc1zE4iysloguKSWk5op6kAj-j_N7loY4KfvDIltZXW6JyqVxWjvnOX3mWYGyhYIVZHe-4lOWYHLrvoPIx5nQlMQ=';
39+
const expectedDecrypted = '{"strategy":"password_only"}';
40+
41+
// First verify we can decrypt the actual filestash value
42+
const decrypted = decryptFilestashConfig(actualSecretKey, actualEncryptedParams);
43+
expect(decrypted).toBe(expectedDecrypted);
44+
45+
// Now encrypt the same value and verify it decrypts correctly
46+
const ourEncrypted = encryptFilestashConfig(actualSecretKey, expectedDecrypted);
47+
const roundTrip = decryptFilestashConfig(actualSecretKey, ourEncrypted);
48+
expect(roundTrip).toBe(expectedDecrypted);
49+
});
50+
51+
it('encodes and decodes ascii string correctly', () => {
52+
const input = 'hello world!';
53+
const buf = Buffer.from(input, 'utf-8');
54+
const encoded = bufferToBase64url(buf);
55+
const decoded = base64urlToBuffer(encoded);
56+
expect(decoded.toString('utf-8')).toBe(input);
57+
});
58+
59+
it('encodes and decodes binary data correctly', () => {
60+
const input = Buffer.from([0, 255, 100, 200, 50, 0, 1, 2, 3, 4, 5]);
61+
const encoded = bufferToBase64url(input);
62+
const decoded = base64urlToBuffer(encoded);
63+
expect(decoded.equals(input)).toBe(true);
64+
});
65+
66+
it('decodes Go base64.URLEncoding output', () => {
67+
// This is base64.URLEncoding of 'hello world!'
68+
const goEncoded = 'aGVsbG8gd29ybGQh';
69+
const decoded = base64urlToBuffer(goEncoded);
70+
expect(decoded.toString('utf-8')).toBe('hello world!');
71+
});
72+
73+
it('handles padding and no padding', () => {
74+
const padded = 'aGVsbG8='; // 'hello' with padding
75+
const unpadded = 'aGVsbG8'; // 'hello' without padding
76+
expect(base64urlToBuffer(padded).toString('utf-8')).toBe('hello');
77+
expect(base64urlToBuffer(unpadded).toString('utf-8')).toBe('hello');
78+
});
79+
80+
it('bufferToBase64url output is always padded to a multiple of 4', () => {
81+
const inputs = [
82+
Buffer.from('hello'),
83+
Buffer.from('hello world!'),
84+
Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
85+
Buffer.from('a'.repeat(15)),
86+
Buffer.from('a'.repeat(16)),
87+
Buffer.from('a'.repeat(17)),
88+
];
89+
for (const buf of inputs) {
90+
const encoded = bufferToBase64url(buf);
91+
expect(encoded.length % 4).toBe(0);
92+
// If not empty, should end with 0-2 '='
93+
if (encoded.length > 0) {
94+
expect(encoded.endsWith('=') || encoded.endsWith('==') || !encoded.includes('=')).toBe(true);
95+
}
96+
}
97+
});
98+
});

app/lib/filestash/crypto.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
2+
import {
3+
randomInt,
4+
createHash,
5+
randomBytes,
6+
createCipheriv,
7+
createDecipheriv
8+
} from "crypto";
9+
import * as zlib from "zlib";
10+
11+
/**
12+
* Generates a cryptographically secure random 16-character alphanumeric string (a-zA-Z0-9),
13+
* matching Filestash's secret_key requirements.
14+
*/
15+
export function generateFilestashSecretKey(): string {
16+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
17+
let result = '';
18+
for (let i = 0; i < 16; i++) {
19+
const idx = randomInt(chars.length);
20+
result += chars[idx];
21+
}
22+
return result;
23+
}
24+
25+
26+
// --- Filestash custom Hash function (base62 encoding of SHA256 digest) ---
27+
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split("");
28+
29+
export type FilestashDerivedKeys = {
30+
proof: string;
31+
admin: string;
32+
user: string;
33+
hash: string;
34+
signature: string;
35+
};
36+
37+
/**
38+
* Derives all Filestash key variants from a secret key, matching Go's InitSecretDerivate.
39+
*/
40+
export function deriveFilestashKeys(secretKey: string): FilestashDerivedKeys {
41+
return {
42+
proof: filestashHash("PROOF_" + secretKey, secretKey.length),
43+
admin: filestashHash("ADMIN_" + secretKey, secretKey.length),
44+
user: filestashHash("USER_" + secretKey, secretKey.length),
45+
hash: filestashHash("HASH_" + secretKey, secretKey.length),
46+
signature: filestashHash("SGN_" + secretKey, secretKey.length),
47+
};
48+
}
49+
50+
function reversedBaseChange(alphabet: string[], i: number): string {
51+
let str = "";
52+
do {
53+
str += alphabet[i % alphabet.length];
54+
i = Math.floor(i / alphabet.length);
55+
} while (i > 0);
56+
return str;
57+
}
58+
59+
function hashSize(b: Buffer, n: number): string {
60+
let h = "";
61+
for (let i = 0; i < b.length; i++) {
62+
if (n > 0 && h.length >= n) break;
63+
h += reversedBaseChange(LETTERS, b[i]);
64+
}
65+
if (h.length > n) {
66+
return h.slice(0, n);
67+
}
68+
return h;
69+
}
70+
71+
function filestashHash(str: string, n: number): string {
72+
const hash = createHash("sha256").update(str).digest();
73+
return hashSize(hash, n);
74+
}
75+
76+
function getAesKey(secretKey: string): Buffer {
77+
const derivedKeys = deriveFilestashKeys(secretKey);
78+
const aesKeyStr = filestashHash(derivedKeys.proof, 16);
79+
let key = Buffer.alloc(16);
80+
Buffer.from(aesKeyStr, "utf-8").copy(key);
81+
return key;
82+
}
83+
84+
export function base64urlToBuffer(str: string): Buffer {
85+
return Buffer.from(str, "base64url");
86+
}
87+
88+
export function bufferToBase64url(buf: Buffer): string {
89+
// Use base64, then replace chars, but keep padding
90+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
91+
}
92+
93+
function compressZlib(data: Buffer): Buffer {
94+
return zlib.deflateSync(data);
95+
}
96+
97+
function decompressZlib(data: Buffer): Buffer {
98+
return zlib.inflateSync(data);
99+
}
100+
101+
function encryptAESGCM(key: Buffer, data: Buffer): Buffer {
102+
const nonceSize = 12;
103+
const nonce = randomBytes(nonceSize);
104+
const cipher = createCipheriv("aes-128-gcm", key, nonce);
105+
const enc = Buffer.concat([cipher.update(data), cipher.final()]);
106+
const tag = cipher.getAuthTag();
107+
return Buffer.concat([nonce, enc, tag]);
108+
}
109+
110+
function decryptAESGCM(key: Buffer, data: Buffer): Buffer {
111+
const nonceSize = 12;
112+
const nonce = data.subarray(0, nonceSize);
113+
const ciphertext = data.subarray(nonceSize);
114+
const tag = ciphertext.subarray(ciphertext.length - 16);
115+
const enc = ciphertext.subarray(0, ciphertext.length - 16);
116+
const decipher = createDecipheriv("aes-128-gcm", key, nonce);
117+
(decipher as any).setAuthTag(tag);
118+
return Buffer.concat([decipher.update(enc), decipher.final()]);
119+
}
120+
121+
/**
122+
* Encrypts a string using Filestash's config encryption logic.
123+
* Returns a base64url-encoded string.
124+
*/
125+
export function encryptFilestashConfig(secretKey: string, plaintext: string): string {
126+
const key = getAesKey(secretKey);
127+
const compressed = compressZlib(Buffer.from(plaintext, "utf-8"));
128+
const encrypted = encryptAESGCM(key, compressed);
129+
return bufferToBase64url(encrypted);
130+
}
131+
132+
/**
133+
* Decrypts a Filestash config value using the secret key.
134+
* Returns the decrypted string.
135+
*/
136+
export function decryptFilestashConfig(secretKey: string, encrypted: string): string {
137+
const key = getAesKey(secretKey);
138+
const encryptedBuf = base64urlToBuffer(encrypted);
139+
const decrypted = decryptAESGCM(key, encryptedBuf);
140+
const decompressed = decompressZlib(decrypted);
141+
return decompressed.toString("utf-8");
142+
}

0 commit comments

Comments
 (0)