@gallop.software/studio 0.1.106 → 0.1.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -150,7 +150,7 @@ async function processImage(buffer, imageKey) {
150
150
  // src/handlers/utils/cdn.ts
151
151
  import { promises as fs3 } from "fs";
152
152
  import path4 from "path";
153
- import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
153
+ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
154
154
  function getR2Client() {
155
155
  const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
156
156
  const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
@@ -210,6 +210,56 @@ async function deleteLocalThumbnails(imageKey) {
210
210
  }
211
211
  }
212
212
  }
213
+ async function downloadFromRemoteUrl(url) {
214
+ const response = await fetch(url);
215
+ if (!response.ok) {
216
+ throw new Error(`Failed to download from ${url}: ${response.status}`);
217
+ }
218
+ const arrayBuffer = await response.arrayBuffer();
219
+ return Buffer.from(arrayBuffer);
220
+ }
221
+ async function uploadOriginalToCdn(imageKey) {
222
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
223
+ if (!bucketName) throw new Error("R2 bucket not configured");
224
+ const r2 = getR2Client();
225
+ const localPath = path4.join(process.cwd(), "public", imageKey);
226
+ const fileBuffer = await fs3.readFile(localPath);
227
+ await r2.send(
228
+ new PutObjectCommand({
229
+ Bucket: bucketName,
230
+ Key: imageKey.replace(/^\//, ""),
231
+ Body: fileBuffer,
232
+ ContentType: getContentType(imageKey)
233
+ })
234
+ );
235
+ }
236
+ async function deleteFromCdn(imageKey, hasThumbnails) {
237
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
238
+ if (!bucketName) throw new Error("R2 bucket not configured");
239
+ const r2 = getR2Client();
240
+ try {
241
+ await r2.send(
242
+ new DeleteObjectCommand({
243
+ Bucket: bucketName,
244
+ Key: imageKey.replace(/^\//, "")
245
+ })
246
+ );
247
+ } catch {
248
+ }
249
+ if (hasThumbnails) {
250
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
251
+ try {
252
+ await r2.send(
253
+ new DeleteObjectCommand({
254
+ Bucket: bucketName,
255
+ Key: thumbPath.replace(/^\//, "")
256
+ })
257
+ );
258
+ } catch {
259
+ }
260
+ }
261
+ }
262
+ }
213
263
 
214
264
  // src/handlers/list.ts
215
265
  async function handleList(request) {
@@ -744,49 +794,103 @@ async function handleMove(request) {
744
794
  if (!absoluteDestination.startsWith(path6.join(process.cwd(), "public"))) {
745
795
  return NextResponse2.json({ error: "Invalid destination" }, { status: 400 });
746
796
  }
747
- try {
748
- const destStats = await fs5.stat(absoluteDestination);
749
- if (!destStats.isDirectory()) {
750
- return NextResponse2.json({ error: "Destination is not a folder" }, { status: 400 });
751
- }
752
- } catch {
753
- return NextResponse2.json({ error: "Destination folder not found" }, { status: 404 });
754
- }
797
+ await fs5.mkdir(absoluteDestination, { recursive: true });
755
798
  const moved = [];
756
799
  const errors = [];
757
800
  const meta = await loadMeta();
801
+ const cdnUrls = getCdnUrls(meta);
802
+ const r2PublicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "") || "";
758
803
  let metaChanged = false;
759
804
  for (const itemPath of paths) {
760
805
  const safePath = itemPath.replace(/\.\./g, "");
761
- const absolutePath = path6.join(process.cwd(), safePath);
762
806
  const itemName = path6.basename(safePath);
763
807
  const newAbsolutePath = path6.join(absoluteDestination, itemName);
764
- if (absoluteDestination.startsWith(absolutePath + path6.sep)) {
765
- errors.push(`Cannot move ${itemName} into itself`);
766
- continue;
767
- }
768
- try {
769
- await fs5.access(absolutePath);
770
- } catch {
771
- errors.push(`${itemName} not found`);
772
- continue;
773
- }
774
- try {
775
- await fs5.access(newAbsolutePath);
808
+ const oldRelativePath = safePath.replace(/^public\//, "");
809
+ const newRelativePath = path6.join(safeDestination.replace(/^public\//, ""), itemName);
810
+ const oldKey = "/" + oldRelativePath;
811
+ const newKey = "/" + newRelativePath;
812
+ if (meta[newKey]) {
776
813
  errors.push(`${itemName} already exists in destination`);
777
814
  continue;
778
- } catch {
779
815
  }
816
+ const entry = meta[oldKey];
817
+ const isImage = isImageFile(itemName);
818
+ const isInCloud = entry?.c !== void 0;
819
+ const fileCdnUrl = isInCloud && entry.c !== void 0 ? cdnUrls[entry.c] : void 0;
820
+ const isRemote = isInCloud && (!r2PublicUrl || fileCdnUrl !== r2PublicUrl);
821
+ const isPushedToR2 = isInCloud && r2PublicUrl && fileCdnUrl === r2PublicUrl;
822
+ const hasProcessedThumbnails = entry?.p === 1;
780
823
  try {
781
- await fs5.rename(absolutePath, newAbsolutePath);
782
- const stats = await fs5.stat(newAbsolutePath);
783
- if (stats.isFile() && isImageFile(itemName)) {
784
- const oldRelativePath = safePath.replace(/^public\//, "");
785
- const newRelativePath = path6.join(safeDestination.replace(/^public\//, ""), itemName);
786
- const oldKey = "/" + oldRelativePath;
787
- const newKey = "/" + newRelativePath;
788
- if (meta[oldKey]) {
789
- const entry = meta[oldKey];
824
+ if (isRemote && isImage) {
825
+ const remoteUrl = `${fileCdnUrl}${oldKey}`;
826
+ const buffer = await downloadFromRemoteUrl(remoteUrl);
827
+ await fs5.mkdir(path6.dirname(newAbsolutePath), { recursive: true });
828
+ await fs5.writeFile(newAbsolutePath, buffer);
829
+ const newEntry = {
830
+ w: entry?.w,
831
+ h: entry?.h,
832
+ b: entry?.b
833
+ // Don't copy p since remote images don't have local thumbnails
834
+ // Don't copy c since it's now local
835
+ };
836
+ delete meta[oldKey];
837
+ meta[newKey] = newEntry;
838
+ metaChanged = true;
839
+ moved.push(itemPath);
840
+ } else if (isPushedToR2 && isImage) {
841
+ const buffer = await downloadFromCdn(oldKey);
842
+ await fs5.mkdir(path6.dirname(newAbsolutePath), { recursive: true });
843
+ await fs5.writeFile(newAbsolutePath, buffer);
844
+ const newEntry = {
845
+ w: entry?.w,
846
+ h: entry?.h,
847
+ b: entry?.b
848
+ };
849
+ if (hasProcessedThumbnails) {
850
+ const processedEntry = await processImage(buffer, newKey);
851
+ newEntry.w = processedEntry.w;
852
+ newEntry.h = processedEntry.h;
853
+ newEntry.b = processedEntry.b;
854
+ newEntry.p = 1;
855
+ }
856
+ await uploadOriginalToCdn(newKey);
857
+ if (hasProcessedThumbnails) {
858
+ await uploadToCdn(newKey);
859
+ }
860
+ await deleteFromCdn(oldKey, hasProcessedThumbnails);
861
+ try {
862
+ await fs5.unlink(newAbsolutePath);
863
+ } catch {
864
+ }
865
+ if (hasProcessedThumbnails) {
866
+ await deleteLocalThumbnails(newKey);
867
+ }
868
+ newEntry.c = entry?.c;
869
+ delete meta[oldKey];
870
+ meta[newKey] = newEntry;
871
+ metaChanged = true;
872
+ moved.push(itemPath);
873
+ } else {
874
+ const absolutePath = path6.join(process.cwd(), safePath);
875
+ if (absoluteDestination.startsWith(absolutePath + path6.sep)) {
876
+ errors.push(`Cannot move ${itemName} into itself`);
877
+ continue;
878
+ }
879
+ try {
880
+ await fs5.access(absolutePath);
881
+ } catch {
882
+ errors.push(`${itemName} not found`);
883
+ continue;
884
+ }
885
+ try {
886
+ await fs5.access(newAbsolutePath);
887
+ errors.push(`${itemName} already exists in destination`);
888
+ continue;
889
+ } catch {
890
+ }
891
+ await fs5.rename(absolutePath, newAbsolutePath);
892
+ const stats = await fs5.stat(newAbsolutePath);
893
+ if (stats.isFile() && isImage && entry) {
790
894
  const oldThumbPaths = getAllThumbnailPaths(oldKey);
791
895
  const newThumbPaths = getAllThumbnailPaths(newKey);
792
896
  for (let i = 0; i < oldThumbPaths.length; i++) {
@@ -801,10 +905,22 @@ async function handleMove(request) {
801
905
  delete meta[oldKey];
802
906
  meta[newKey] = entry;
803
907
  metaChanged = true;
908
+ } else if (stats.isDirectory()) {
909
+ const oldPrefix = oldKey + "/";
910
+ const newPrefix = newKey + "/";
911
+ for (const key of Object.keys(meta)) {
912
+ if (key.startsWith(oldPrefix)) {
913
+ const newMetaKey = newPrefix + key.slice(oldPrefix.length);
914
+ meta[newMetaKey] = meta[key];
915
+ delete meta[key];
916
+ metaChanged = true;
917
+ }
918
+ }
804
919
  }
920
+ moved.push(itemPath);
805
921
  }
806
- moved.push(itemPath);
807
- } catch {
922
+ } catch (err) {
923
+ console.error(`Failed to move ${itemName}:`, err);
808
924
  errors.push(`Failed to move ${itemName}`);
809
925
  }
810
926
  }