Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

check manifest and calculate CIDs then compare if we need to reupload blobs

+18 -1
bun.lock
··· 25 25 "elysia": "latest", 26 26 "iron-session": "^8.0.4", 27 27 "lucide-react": "^0.546.0", 28 + "multiformats": "^13.4.1", 28 29 "react": "^19.2.0", 29 30 "react-dom": "^19.2.0", 30 31 "react-shiki": "^0.9.0", ··· 641 642 642 643 "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 643 644 644 - "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 645 + "multiformats": ["multiformats@13.4.1", "", {}, "sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q=="], 645 646 646 647 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], 647 648 ··· 857 858 858 859 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 859 860 861 + "@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 862 + 863 + "@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 864 + 865 + "@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 866 + 867 + "@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 868 + 869 + "@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 870 + 871 + "@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 872 + 873 + "@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 874 + 860 875 "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 861 876 862 877 "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], ··· 882 897 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 883 898 884 899 "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 900 + 901 + "uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 885 902 886 903 "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 887 904
+1
package.json
··· 29 29 "elysia": "latest", 30 30 "iron-session": "^8.0.4", 31 31 "lucide-react": "^0.546.0", 32 + "multiformats": "^13.4.1", 32 33 "react": "^19.2.0", 33 34 "react-dom": "^19.2.0", 34 35 "react-shiki": "^0.9.0",
+2 -2
public/editor/editor.tsx
··· 748 748 749 749 <div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50"> 750 750 <div className="flex items-start gap-2"> 751 - <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" /> 751 + <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" /> 752 752 <div className="flex-1 space-y-1"> 753 753 <p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400"> 754 754 Note about sites.wisp.place URLs ··· 1120 1120 {skippedFiles.length > 0 && ( 1121 1121 <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 1122 1122 <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 1123 - <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 1123 + <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 1124 1124 <div className="flex-1"> 1125 1125 <span className="font-medium"> 1126 1126 {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
-7
src/lib/db.ts
··· 244 244 245 245 const stateStore = { 246 246 async set(key: string, data: any) { 247 - console.debug('[stateStore] set', key) 248 247 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 249 248 await db` 250 249 INSERT INTO oauth_states (key, data, created_at, expires_at) ··· 253 252 `; 254 253 }, 255 254 async get(key: string) { 256 - console.debug('[stateStore] get', key) 257 255 const now = Math.floor(Date.now() / 1000); 258 256 const result = await db` 259 257 SELECT data, expires_at ··· 265 263 // Check if expired 266 264 const expiresAt = Number(result[0].expires_at); 267 265 if (expiresAt && now > expiresAt) { 268 - console.debug('[stateStore] State expired, deleting', key); 269 266 await db`DELETE FROM oauth_states WHERE key = ${key}`; 270 267 return undefined; 271 268 } ··· 273 270 return JSON.parse(result[0].data); 274 271 }, 275 272 async del(key: string) { 276 - console.debug('[stateStore] del', key) 277 273 await db`DELETE FROM oauth_states WHERE key = ${key}`; 278 274 } 279 275 }; 280 276 281 277 const sessionStore = { 282 278 async set(sub: string, data: any) { 283 - console.debug('[sessionStore] set', sub) 284 279 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 285 280 await db` 286 281 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) ··· 292 287 `; 293 288 }, 294 289 async get(sub: string) { 295 - console.debug('[sessionStore] get', sub) 296 290 const now = Math.floor(Date.now() / 1000); 297 291 const result = await db` 298 292 SELECT data, expires_at ··· 312 306 return JSON.parse(result[0].data); 313 307 }, 314 308 async del(sub: string) { 315 - console.debug('[sessionStore] del', sub) 316 309 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 317 310 } 318 311 };
-1
src/lib/oauth-client.ts
··· 58 58 `; 59 59 }, 60 60 async get(sub: string) { 61 - console.debug('[sessionStore] get', sub) 62 61 const now = Math.floor(Date.now() / 1000); 63 62 const result = await db` 64 63 SELECT data, expires_at
+360
src/lib/wisp-utils.test.ts
··· 5 5 processUploadedFiles, 6 6 createManifest, 7 7 updateFileBlobs, 8 + computeCID, 9 + extractBlobMap, 8 10 type UploadedFile, 9 11 type FileUploadResult, 10 12 } from './wisp-utils' ··· 637 639 } 638 640 }) 639 641 }) 642 + 643 + describe('computeCID', () => { 644 + test('should compute CID for gzipped+base64 encoded content', () => { 645 + // This simulates the actual flow: gzip -> base64 -> compute CID 646 + const originalContent = Buffer.from('Hello, World!') 647 + const gzipped = compressFile(originalContent) 648 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 649 + 650 + const cid = computeCID(base64Content) 651 + 652 + // CID should be a valid CIDv1 string starting with 'bafkrei' 653 + expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 654 + expect(cid.length).toBeGreaterThan(10) 655 + }) 656 + 657 + test('should compute deterministic CIDs for identical content', () => { 658 + const content = Buffer.from('Test content for CID calculation') 659 + const gzipped = compressFile(content) 660 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 661 + 662 + const cid1 = computeCID(base64Content) 663 + const cid2 = computeCID(base64Content) 664 + 665 + expect(cid1).toBe(cid2) 666 + }) 667 + 668 + test('should compute different CIDs for different content', () => { 669 + const content1 = Buffer.from('Content A') 670 + const content2 = Buffer.from('Content B') 671 + 672 + const gzipped1 = compressFile(content1) 673 + const gzipped2 = compressFile(content2) 674 + 675 + const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary') 676 + const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary') 677 + 678 + const cid1 = computeCID(base64Content1) 679 + const cid2 = computeCID(base64Content2) 680 + 681 + expect(cid1).not.toBe(cid2) 682 + }) 683 + 684 + test('should handle empty content', () => { 685 + const emptyContent = Buffer.from('') 686 + const gzipped = compressFile(emptyContent) 687 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 688 + 689 + const cid = computeCID(base64Content) 690 + 691 + expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 692 + }) 693 + 694 + test('should compute same CID as PDS for base64-encoded content', () => { 695 + // Test that binary encoding produces correct bytes for CID calculation 696 + const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>') 697 + const gzipped = compressFile(testContent) 698 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 699 + 700 + // Compute CID twice to ensure consistency 701 + const cid1 = computeCID(base64Content) 702 + const cid2 = computeCID(base64Content) 703 + 704 + expect(cid1).toBe(cid2) 705 + expect(cid1).toMatch(/^bafkrei/) 706 + }) 707 + 708 + test('should use binary encoding for base64 strings', () => { 709 + // This test verifies we're using the correct encoding method 710 + // For base64 strings, 'binary' encoding ensures each character becomes exactly one byte 711 + const content = Buffer.from('Test content') 712 + const gzipped = compressFile(content) 713 + const base64String = gzipped.toString('base64') 714 + 715 + // Using binary encoding (what we use in production) 716 + const base64Content = Buffer.from(base64String, 'binary') 717 + 718 + // Verify the length matches the base64 string length 719 + expect(base64Content.length).toBe(base64String.length) 720 + 721 + // Verify CID is computed correctly 722 + const cid = computeCID(base64Content) 723 + expect(cid).toMatch(/^bafkrei/) 724 + }) 725 + }) 726 + 727 + describe('extractBlobMap', () => { 728 + test('should extract blob map from flat directory structure', () => { 729 + const mockCid = CID.parse(TEST_CID_STRING) 730 + const mockBlob = new BlobRef(mockCid, 'text/html', 100) 731 + 732 + const directory: Directory = { 733 + $type: 'place.wisp.fs#directory', 734 + type: 'directory', 735 + entries: [ 736 + { 737 + name: 'index.html', 738 + node: { 739 + $type: 'place.wisp.fs#file', 740 + type: 'file', 741 + blob: mockBlob, 742 + }, 743 + }, 744 + ], 745 + } 746 + 747 + const blobMap = extractBlobMap(directory) 748 + 749 + expect(blobMap.size).toBe(1) 750 + expect(blobMap.has('index.html')).toBe(true) 751 + 752 + const entry = blobMap.get('index.html') 753 + expect(entry?.cid).toBe(TEST_CID_STRING) 754 + expect(entry?.blobRef).toBe(mockBlob) 755 + }) 756 + 757 + test('should extract blob map from nested directory structure', () => { 758 + const mockCid1 = CID.parse(TEST_CID_STRING) 759 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 760 + 761 + const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100) 762 + const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50) 763 + 764 + const directory: Directory = { 765 + $type: 'place.wisp.fs#directory', 766 + type: 'directory', 767 + entries: [ 768 + { 769 + name: 'index.html', 770 + node: { 771 + $type: 'place.wisp.fs#file', 772 + type: 'file', 773 + blob: mockBlob1, 774 + }, 775 + }, 776 + { 777 + name: 'assets', 778 + node: { 779 + $type: 'place.wisp.fs#directory', 780 + type: 'directory', 781 + entries: [ 782 + { 783 + name: 'styles.css', 784 + node: { 785 + $type: 'place.wisp.fs#file', 786 + type: 'file', 787 + blob: mockBlob2, 788 + }, 789 + }, 790 + ], 791 + }, 792 + }, 793 + ], 794 + } 795 + 796 + const blobMap = extractBlobMap(directory) 797 + 798 + expect(blobMap.size).toBe(2) 799 + expect(blobMap.has('index.html')).toBe(true) 800 + expect(blobMap.has('assets/styles.css')).toBe(true) 801 + 802 + expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING) 803 + expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 804 + }) 805 + 806 + test('should handle deeply nested directory structures', () => { 807 + const mockCid = CID.parse(TEST_CID_STRING) 808 + const mockBlob = new BlobRef(mockCid, 'text/javascript', 200) 809 + 810 + const directory: Directory = { 811 + $type: 'place.wisp.fs#directory', 812 + type: 'directory', 813 + entries: [ 814 + { 815 + name: 'src', 816 + node: { 817 + $type: 'place.wisp.fs#directory', 818 + type: 'directory', 819 + entries: [ 820 + { 821 + name: 'lib', 822 + node: { 823 + $type: 'place.wisp.fs#directory', 824 + type: 'directory', 825 + entries: [ 826 + { 827 + name: 'utils.js', 828 + node: { 829 + $type: 'place.wisp.fs#file', 830 + type: 'file', 831 + blob: mockBlob, 832 + }, 833 + }, 834 + ], 835 + }, 836 + }, 837 + ], 838 + }, 839 + }, 840 + ], 841 + } 842 + 843 + const blobMap = extractBlobMap(directory) 844 + 845 + expect(blobMap.size).toBe(1) 846 + expect(blobMap.has('src/lib/utils.js')).toBe(true) 847 + expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING) 848 + }) 849 + 850 + test('should handle empty directory', () => { 851 + const directory: Directory = { 852 + $type: 'place.wisp.fs#directory', 853 + type: 'directory', 854 + entries: [], 855 + } 856 + 857 + const blobMap = extractBlobMap(directory) 858 + 859 + expect(blobMap.size).toBe(0) 860 + }) 861 + 862 + test('should correctly extract CID from BlobRef instances (not plain objects)', () => { 863 + // This test verifies the fix: AT Protocol SDK returns BlobRef instances, 864 + // not plain objects with $type and $link properties 865 + const mockCid = CID.parse(TEST_CID_STRING) 866 + const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500) 867 + 868 + const directory: Directory = { 869 + $type: 'place.wisp.fs#directory', 870 + type: 'directory', 871 + entries: [ 872 + { 873 + name: 'test.bin', 874 + node: { 875 + $type: 'place.wisp.fs#file', 876 + type: 'file', 877 + blob: mockBlob, 878 + }, 879 + }, 880 + ], 881 + } 882 + 883 + const blobMap = extractBlobMap(directory) 884 + 885 + // The fix: we call .toString() on the CID instance instead of accessing $link 886 + expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING) 887 + expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING) 888 + }) 889 + 890 + test('should handle multiple files in same directory', () => { 891 + const mockCid1 = CID.parse(TEST_CID_STRING) 892 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 893 + const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 894 + 895 + const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000) 896 + const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000) 897 + const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000) 898 + 899 + const directory: Directory = { 900 + $type: 'place.wisp.fs#directory', 901 + type: 'directory', 902 + entries: [ 903 + { 904 + name: 'images', 905 + node: { 906 + $type: 'place.wisp.fs#directory', 907 + type: 'directory', 908 + entries: [ 909 + { 910 + name: 'logo.png', 911 + node: { 912 + $type: 'place.wisp.fs#file', 913 + type: 'file', 914 + blob: mockBlob1, 915 + }, 916 + }, 917 + { 918 + name: 'banner.png', 919 + node: { 920 + $type: 'place.wisp.fs#file', 921 + type: 'file', 922 + blob: mockBlob2, 923 + }, 924 + }, 925 + { 926 + name: 'icon.png', 927 + node: { 928 + $type: 'place.wisp.fs#file', 929 + type: 'file', 930 + blob: mockBlob3, 931 + }, 932 + }, 933 + ], 934 + }, 935 + }, 936 + ], 937 + } 938 + 939 + const blobMap = extractBlobMap(directory) 940 + 941 + expect(blobMap.size).toBe(3) 942 + expect(blobMap.has('images/logo.png')).toBe(true) 943 + expect(blobMap.has('images/banner.png')).toBe(true) 944 + expect(blobMap.has('images/icon.png')).toBe(true) 945 + }) 946 + 947 + test('should handle mixed directory and file structure', () => { 948 + const mockCid1 = CID.parse(TEST_CID_STRING) 949 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 950 + const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 951 + 952 + const directory: Directory = { 953 + $type: 'place.wisp.fs#directory', 954 + type: 'directory', 955 + entries: [ 956 + { 957 + name: 'index.html', 958 + node: { 959 + $type: 'place.wisp.fs#file', 960 + type: 'file', 961 + blob: new BlobRef(mockCid1, 'text/html', 100), 962 + }, 963 + }, 964 + { 965 + name: 'assets', 966 + node: { 967 + $type: 'place.wisp.fs#directory', 968 + type: 'directory', 969 + entries: [ 970 + { 971 + name: 'styles.css', 972 + node: { 973 + $type: 'place.wisp.fs#file', 974 + type: 'file', 975 + blob: new BlobRef(mockCid2, 'text/css', 50), 976 + }, 977 + }, 978 + ], 979 + }, 980 + }, 981 + { 982 + name: 'README.md', 983 + node: { 984 + $type: 'place.wisp.fs#file', 985 + type: 'file', 986 + blob: new BlobRef(mockCid3, 'text/markdown', 200), 987 + }, 988 + }, 989 + ], 990 + } 991 + 992 + const blobMap = extractBlobMap(directory) 993 + 994 + expect(blobMap.size).toBe(3) 995 + expect(blobMap.has('index.html')).toBe(true) 996 + expect(blobMap.has('assets/styles.css')).toBe(true) 997 + expect(blobMap.has('README.md')).toBe(true) 998 + }) 999 + })
+65 -2
src/lib/wisp-utils.ts
··· 2 2 import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs"; 3 3 import { validateRecord } from "../lexicons/types/place/wisp/fs"; 4 4 import { gzipSync } from 'zlib'; 5 + import { CID } from 'multiformats/cid'; 6 + import { sha256 } from 'multiformats/hashes/sha2'; 7 + import * as raw from 'multiformats/codecs/raw'; 8 + import { createHash } from 'crypto'; 9 + import * as mf from 'multiformats'; 5 10 6 11 export interface UploadedFile { 7 12 name: string; ··· 48 53 } 49 54 50 55 /** 51 - * Compress a file using gzip 56 + * Compress a file using gzip with deterministic output 57 + * Sets mtime to 0 to ensure identical content produces identical compressed output 52 58 */ 53 59 export function compressFile(content: Buffer): Buffer { 54 - return gzipSync(content, { level: 9 }); 60 + return gzipSync(content, { 61 + level: 9, 62 + mtime: 0 // Fixed timestamp for deterministic compression 63 + }); 55 64 } 56 65 57 66 /** ··· 65 74 const directoryMap = new Map<string, UploadedFile[]>(); 66 75 67 76 for (const file of files) { 77 + // Skip undefined/null files (defensive) 78 + if (!file || !file.name) { 79 + console.error('Skipping undefined or invalid file in processUploadedFiles'); 80 + continue; 81 + } 82 + 68 83 // Remove any base folder name from the path 69 84 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 70 85 const parts = normalizedPath.split('/'); ··· 239 254 240 255 return result; 241 256 } 257 + 258 + /** 259 + * Compute CID (Content Identifier) for blob content 260 + * Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256 261 + * Based on @atproto/common/src/ipld.ts sha256RawToCid implementation 262 + */ 263 + export function computeCID(content: Buffer): string { 264 + // Use node crypto to compute sha256 hash (same as AT Protocol) 265 + const hash = createHash('sha256').update(content).digest(); 266 + // Create digest object from hash bytes 267 + const digest = mf.digest.create(sha256.code, hash); 268 + // Create CIDv1 with raw codec 269 + const cid = CID.createV1(raw.code, digest); 270 + return cid.toString(); 271 + } 272 + 273 + /** 274 + * Extract blob information from a directory tree 275 + * Returns a map of file paths to their blob refs and CIDs 276 + */ 277 + export function extractBlobMap( 278 + directory: Directory, 279 + currentPath: string = '' 280 + ): Map<string, { blobRef: BlobRef; cid: string }> { 281 + const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>(); 282 + 283 + for (const entry of directory.entries) { 284 + const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 285 + 286 + if ('type' in entry.node && entry.node.type === 'file') { 287 + const fileNode = entry.node as File; 288 + // AT Protocol SDK returns BlobRef class instances, not plain objects 289 + // The ref is a CID instance that can be converted to string 290 + if (fileNode.blob && fileNode.blob.ref) { 291 + const cidString = fileNode.blob.ref.toString(); 292 + blobMap.set(fullPath, { 293 + blobRef: fileNode.blob, 294 + cid: cidString 295 + }); 296 + } 297 + } else if ('type' in entry.node && entry.node.type === 'directory') { 298 + const subMap = extractBlobMap(entry.node as Directory, fullPath); 299 + subMap.forEach((value, key) => blobMap.set(key, value)); 300 + } 301 + } 302 + 303 + return blobMap; 304 + }
+130 -10
src/routes/wisp.ts
··· 9 9 createManifest, 10 10 updateFileBlobs, 11 11 shouldCompressFile, 12 - compressFile 12 + compressFile, 13 + computeCID, 14 + extractBlobMap 13 15 } from '../lib/wisp-utils' 14 16 import { upsertSite } from '../lib/db' 15 17 import { logger } from '../lib/observability' ··· 48 50 siteName: string; 49 51 files: File | File[] 50 52 }; 53 + 54 + console.log('=== UPLOAD FILES START ==='); 55 + console.log('Site name:', siteName); 56 + console.log('Files received:', Array.isArray(files) ? files.length : 'single file'); 51 57 52 58 try { 53 59 if (!siteName) { ··· 106 112 107 113 // Create agent with OAuth session 108 114 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 115 + console.log('Agent created for DID:', auth.did); 116 + 117 + // Try to fetch existing record to enable incremental updates 118 + let existingBlobMap = new Map<string, { blobRef: any; cid: string }>(); 119 + console.log('Attempting to fetch existing record...'); 120 + try { 121 + const rkey = siteName; 122 + const existingRecord = await agent.com.atproto.repo.getRecord({ 123 + repo: auth.did, 124 + collection: 'place.wisp.fs', 125 + rkey: rkey 126 + }); 127 + console.log('Existing record found!'); 128 + 129 + if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) { 130 + const manifest = existingRecord.data.value as any; 131 + existingBlobMap = extractBlobMap(manifest.root); 132 + console.log(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 133 + logger.info(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 134 + } 135 + } catch (error: any) { 136 + console.log('No existing record found or error:', error?.message || error); 137 + // Record doesn't exist yet, this is a new site 138 + if (error?.status !== 400 && error?.error !== 'RecordNotFound') { 139 + logger.warn('Failed to fetch existing record, proceeding with full upload', error); 140 + } 141 + } 109 142 110 143 // Convert File objects to UploadedFile format 111 144 // Elysia gives us File objects directly, handle both single file and array ··· 113 146 const uploadedFiles: UploadedFile[] = []; 114 147 const skippedFiles: Array<{ name: string; reason: string }> = []; 115 148 116 - 149 + console.log('Processing files, count:', fileArray.length); 117 150 118 151 for (let i = 0; i < fileArray.length; i++) { 119 152 const file = fileArray[i]; 153 + console.log(`Processing file ${i + 1}/${fileArray.length}:`, file.name, file.size, 'bytes'); 120 154 121 155 // Skip files that are too large (limit to 100MB per file) 122 156 const maxSize = MAX_FILE_SIZE; // 100MB ··· 135 169 // Compress and base64 encode ALL files 136 170 const compressedContent = compressFile(originalContent); 137 171 // Base64 encode the gzipped content to prevent PDS content sniffing 138 - const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); 172 + // Convert base64 string to bytes using binary encoding (each char becomes exactly one byte) 173 + // This is what PDS receives and computes CID on 174 + const base64Content = Buffer.from(compressedContent.toString('base64'), 'binary'); 139 175 const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 176 + console.log(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 140 177 logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 141 178 142 179 uploadedFiles.push({ 143 180 name: file.name, 144 - content: base64Content, 181 + content: base64Content, // This is the gzipped+base64 content that will be uploaded and CID-computed 145 182 mimeType: originalMimeType, 146 183 size: base64Content.length, 147 184 compressed: true, ··· 206 243 } 207 244 208 245 // Process files into directory structure 209 - const { directory, fileCount } = processUploadedFiles(uploadedFiles); 246 + console.log('Processing uploaded files into directory structure...'); 247 + console.log('uploadedFiles array length:', uploadedFiles.length); 248 + console.log('uploadedFiles contents:', uploadedFiles.map((f, i) => `${i}: ${f?.name || 'UNDEFINED'}`)); 210 249 211 - // Upload files as blobs in parallel 250 + // Filter out any undefined/null/invalid entries (defensive) 251 + const validUploadedFiles = uploadedFiles.filter((f, i) => { 252 + if (!f) { 253 + console.error(`Filtering out undefined/null file at index ${i}`); 254 + return false; 255 + } 256 + if (!f.name) { 257 + console.error(`Filtering out file with no name at index ${i}:`, f); 258 + return false; 259 + } 260 + if (!f.content) { 261 + console.error(`Filtering out file with no content at index ${i}:`, f.name); 262 + return false; 263 + } 264 + return true; 265 + }); 266 + if (validUploadedFiles.length !== uploadedFiles.length) { 267 + console.warn(`Filtered out ${uploadedFiles.length - validUploadedFiles.length} invalid files`); 268 + } 269 + console.log('validUploadedFiles length:', validUploadedFiles.length); 270 + 271 + const { directory, fileCount } = processUploadedFiles(validUploadedFiles); 272 + console.log('Directory structure created, file count:', fileCount); 273 + 274 + // Upload files as blobs in parallel (or reuse existing blobs with matching CIDs) 275 + console.log('Starting blob upload/reuse phase...'); 212 276 // For compressed files, we upload as octet-stream and store the original MIME type in metadata 213 277 // For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues 214 - const uploadPromises = uploadedFiles.map(async (file, i) => { 278 + const uploadPromises = validUploadedFiles.map(async (file, i) => { 215 279 try { 280 + // Skip undefined files (shouldn't happen after filter, but defensive) 281 + if (!file || !file.name) { 282 + console.error(`ERROR: Undefined file at index ${i} in validUploadedFiles!`); 283 + throw new Error(`Undefined file at index ${i}`); 284 + } 285 + 286 + // Compute CID for this file to check if it already exists 287 + // Note: file.content is already gzipped+base64 encoded 288 + const fileCID = computeCID(file.content); 289 + 290 + // Normalize the file path for comparison (remove base folder prefix like "cobblemon/") 291 + const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 292 + 293 + // Check if we have an existing blob with the same CID 294 + // Try both the normalized path and the full path 295 + const existingBlob = existingBlobMap.get(normalizedPath) || existingBlobMap.get(file.name); 296 + 297 + if (existingBlob && existingBlob.cid === fileCID) { 298 + // Reuse existing blob - no need to upload 299 + logger.info(`[File Upload] Reusing existing blob for: ${file.name} (CID: ${fileCID})`); 300 + 301 + return { 302 + result: { 303 + hash: existingBlob.cid, 304 + blobRef: existingBlob.blobRef, 305 + ...(file.compressed && { 306 + encoding: 'gzip' as const, 307 + mimeType: file.originalMimeType || file.mimeType, 308 + base64: true 309 + }) 310 + }, 311 + filePath: file.name, 312 + sentMimeType: file.mimeType, 313 + returnedMimeType: existingBlob.blobRef.mimeType, 314 + reused: true 315 + }; 316 + } 317 + 318 + // File is new or changed - upload it 216 319 // If compressed, always upload as octet-stream 217 320 // Otherwise, workaround: PDS incorrectly processes text/html through image pipeline 218 321 const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html') ··· 220 323 : file.mimeType; 221 324 222 325 const compressionInfo = file.compressed ? ' (gzipped)' : ''; 223 - logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); 326 + logger.info(`[File Upload] Uploading new/changed file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo}, CID: ${fileCID})`); 224 327 225 328 const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 329 file.content, ··· 244 347 }, 245 348 filePath: file.name, 246 349 sentMimeType: file.mimeType, 247 - returnedMimeType: returnedBlobRef.mimeType 350 + returnedMimeType: returnedBlobRef.mimeType, 351 + reused: false 248 352 }; 249 353 } catch (uploadError) { 250 354 logger.error('Upload failed for file', uploadError); ··· 255 359 // Wait for all uploads to complete 256 360 const uploadedBlobs = await Promise.all(uploadPromises); 257 361 362 + // Count reused vs uploaded blobs 363 + const reusedCount = uploadedBlobs.filter(b => (b as any).reused).length; 364 + const uploadedCount = uploadedBlobs.filter(b => !(b as any).reused).length; 365 + console.log(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 366 + logger.info(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 367 + 258 368 // Extract results and file paths in correct order 259 369 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result); 260 370 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath); 261 371 262 372 // Update directory with file blobs 373 + console.log('Updating directory with blob references...'); 263 374 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 264 375 265 376 // Create manifest 377 + console.log('Creating manifest...'); 266 378 const manifest = createManifest(siteName, updatedDirectory, fileCount); 379 + console.log('Manifest created successfully'); 267 380 268 381 // Use site name as rkey 269 382 const rkey = siteName; 270 383 271 384 let record; 272 385 try { 386 + console.log('Putting record to PDS with rkey:', rkey); 273 387 record = await agent.com.atproto.repo.putRecord({ 274 388 repo: auth.did, 275 389 collection: 'place.wisp.fs', 276 390 rkey: rkey, 277 391 record: manifest 278 392 }); 393 + console.log('Record successfully created on PDS:', record.data.uri); 279 394 } catch (putRecordError: any) { 395 + console.error('FAILED to create record on PDS:', putRecordError); 280 396 logger.error('Failed to create record on PDS', putRecordError); 281 397 282 398 throw putRecordError; ··· 292 408 fileCount, 293 409 siteName, 294 410 skippedFiles, 295 - uploadedCount: uploadedFiles.length 411 + uploadedCount: validUploadedFiles.length 296 412 }; 297 413 414 + console.log('=== UPLOAD FILES COMPLETE ==='); 298 415 return result; 299 416 } catch (error) { 417 + console.error('=== UPLOAD ERROR ==='); 418 + console.error('Error details:', error); 419 + console.error('Stack trace:', error instanceof Error ? error.stack : 'N/A'); 300 420 logger.error('Upload error', error, { 301 421 message: error instanceof Error ? error.message : 'Unknown error', 302 422 name: error instanceof Error ? error.name : undefined