commit
be5d389b20
37
README.md
37
README.md
@ -68,6 +68,43 @@ Installation
|
|||||||
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
|
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
|
||||||
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar
|
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar
|
||||||
|
|
||||||
|
### Setup alternate storage
|
||||||
|
|
||||||
|
#### AWS S3
|
||||||
|
|
||||||
|
1. [Create bucket](https://s3.console.aws.amazon.com/s3) with "ACLs enabled," and "Block all public access" turned off
|
||||||
|
- Setup CORS:
|
||||||
|
```
|
||||||
|
[{
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"AllowedMethods": [
|
||||||
|
"GET",
|
||||||
|
"PUT"
|
||||||
|
],
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"http://localhost:*",
|
||||||
|
"https://${VERCEL_PROJECT_NAME}*.vercel.app"
|
||||||
|
"{PRODUCTION_DOMAIN}",
|
||||||
|
],
|
||||||
|
"ExposeHeaders": []
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
- Store configuration
|
||||||
|
- `NEXT_PUBLIC_S3_BUCKET`
|
||||||
|
- `NEXT_PUBLIC_S3_REGION`
|
||||||
|
2. [Create IAM policy](https://console.aws.amazon.com/iam/home#/policies) for client uploads (JSON editor recommended)
|
||||||
|
- Action: `s3:PutObject`, `s3:PutObjectACL`
|
||||||
|
- Resource: `arn:aws:s3:::{BUCKET_NAME}/upload-*`
|
||||||
|
3. [Create IAM policy](https://console.aws.amazon.com/iam/home#/policies) for admin actions (JSON editor recommended)
|
||||||
|
- Action: `s3:PutObject`, `s3:PutObjectACL`, `s3:GetObject`, `s3:ListBucket`, `s3:DeleteObject`
|
||||||
|
- Resource: `arn:aws:s3:::{BUCKET_NAME}`, `arn:aws:s3:::{BUCKET_NAME}/*`
|
||||||
|
4. [Create IAM user](https://console.aws.amazon.com/iam/home#/users) for upload policy (by choosing "Attach policies directly"), create access key under "Security credentials," choose "Application running outside AWS," and store credentials
|
||||||
|
- `NEXT_PUBLIC_S3_UPLOAD_ACCESS_KEY`
|
||||||
|
- `NEXT_PUBLIC_S3_UPLOAD_SECRET_ACCESS_KEY`
|
||||||
|
5. [Create IAM user](https://console.aws.amazon.com/iam/home#/users), for admin policy (by choosing "Attach policies directly"), , create access key under "Security credentials," choose "Application running outside AWS," and store credentials (_ensure admin environment variables are not prefixed with `NEXT_PUBLIC`_)
|
||||||
|
- `S3_ADMIN_ACCESS_KEY`
|
||||||
|
- `S3_ADMIN_SECRET_ACCESS_KEY`
|
||||||
|
|
||||||
FAQ
|
FAQ
|
||||||
-
|
-
|
||||||
Q: My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?<br />
|
Q: My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?<br />
|
||||||
|
|||||||
@ -1,24 +1,40 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
||||||
|
|
||||||
const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
|
||||||
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
||||||
)?.[1].toLowerCase();
|
)?.[1].toLowerCase();
|
||||||
|
const VERCEL_BLOB_HOSTNAME = VERCEL_BLOB_STORE_ID
|
||||||
|
? `${VERCEL_BLOB_STORE_ID}.public.blob.vercel-storage.com`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const AWS_S3_HOSTNAME =
|
||||||
|
process.env.NEXT_PUBLIC_S3_BUCKET &&
|
||||||
|
process.env.NEXT_PUBLIC_S3_REGION
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
? `${process.env.NEXT_PUBLIC_S3_BUCKET}.s3.${process.env.NEXT_PUBLIC_S3_REGION}.amazonaws.com`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
imageSizes: [200],
|
||||||
|
remotePatterns: []
|
||||||
|
.concat(VERCEL_BLOB_HOSTNAME ? {
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: VERCEL_BLOB_HOSTNAME,
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
} : [])
|
||||||
|
.concat(AWS_S3_HOSTNAME ? {
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: AWS_S3_HOSTNAME,
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
} : []),
|
||||||
|
minimumCacheTTL: 31536000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||||
enabled: process.env.ANALYZE === 'true',
|
enabled: process.env.ANALYZE === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig = {
|
|
||||||
images: {
|
|
||||||
imageSizes: [200],
|
|
||||||
remotePatterns: [{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: `${STORE_ID}.public.blob.vercel-storage.com`,
|
|
||||||
port: '',
|
|
||||||
pathname: '/**',
|
|
||||||
}],
|
|
||||||
minimumCacheTTL: 31536000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = withBundleAnalyzer(nextConfig);
|
module.exports = withBundleAnalyzer(nextConfig);
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"analyze": "ANALYZE=true next build"
|
"analyze": "ANALYZE=true next build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.456.0",
|
||||||
"@next/bundle-analyzer": "14.0.3",
|
"@next/bundle-analyzer": "14.0.3",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@testing-library/jest-dom": "^6.1.4",
|
"@testing-library/jest-dom": "^6.1.4",
|
||||||
|
|||||||
1047
pnpm-lock.yaml
generated
1047
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
src/cache/index.ts
vendored
2
src/cache/index.ts
vendored
@ -20,7 +20,7 @@ import {
|
|||||||
getUniqueFilmSimulations,
|
getUniqueFilmSimulations,
|
||||||
getPhotosFilmSimulationDateRange,
|
getPhotosFilmSimulationDateRange,
|
||||||
getPhotosFilmSimulationCount,
|
getPhotosFilmSimulationCount,
|
||||||
} from '@/services/postgres';
|
} from '@/services/vercel-postgres';
|
||||||
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
||||||
import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob';
|
import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob';
|
||||||
import type { Session } from 'next-auth';
|
import type { Session } from 'next-auth';
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export default function PhotoUpload({
|
|||||||
blob,
|
blob,
|
||||||
extension,
|
extension,
|
||||||
)
|
)
|
||||||
.then(({ url }) => {
|
.then(url => {
|
||||||
if (isLastBlob) {
|
if (isLastBlob) {
|
||||||
// Refresh page to update upload list,
|
// Refresh page to update upload list,
|
||||||
// relevant to upload count in nav
|
// relevant to upload count in nav
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
sqlUpdatePhoto,
|
sqlUpdatePhoto,
|
||||||
sqlRenamePhotoTagGlobally,
|
sqlRenamePhotoTagGlobally,
|
||||||
getPhoto,
|
getPhoto,
|
||||||
} from '@/services/postgres';
|
} from '@/services/vercel-postgres';
|
||||||
import {
|
import {
|
||||||
PhotoFormData,
|
PhotoFormData,
|
||||||
convertFormDataToPhotoDbInsert,
|
convertFormDataToPhotoDbInsert,
|
||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
convertUploadToPhoto,
|
convertUploadToPhoto,
|
||||||
deleteBlobPhoto,
|
deleteBlobUrl,
|
||||||
} from '@/services/blob';
|
} from '@/services/blob';
|
||||||
import {
|
import {
|
||||||
revalidateAdminPaths,
|
revalidateAdminPaths,
|
||||||
@ -52,7 +52,7 @@ export async function updatePhotoAction(formData: FormData) {
|
|||||||
|
|
||||||
export async function deletePhotoAction(formData: FormData) {
|
export async function deletePhotoAction(formData: FormData) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
deleteBlobPhoto(formData.get('url') as string),
|
deleteBlobUrl(formData.get('url') as string),
|
||||||
sqlDeletePhoto(formData.get('id') as string),
|
sqlDeletePhoto(formData.get('id') as string),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ export async function renamePhotoTagGloballyAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBlobPhotoAction(formData: FormData) {
|
export async function deleteBlobPhotoAction(formData: FormData) {
|
||||||
await deleteBlobPhoto(formData.get('url') as string);
|
await deleteBlobUrl(formData.get('url') as string);
|
||||||
|
|
||||||
revalidateAdminPaths();
|
revalidateAdminPaths();
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob';
|
import {
|
||||||
|
getExtensionFromBlobUrl,
|
||||||
|
getIdFromBlobUrl,
|
||||||
|
} from '@/services/blob';
|
||||||
import { convertExifToFormData } from '@/photo/form';
|
import { convertExifToFormData } from '@/photo/form';
|
||||||
import {
|
import {
|
||||||
getFujifilmSimulationFromMakerNote,
|
getFujifilmSimulationFromMakerNote,
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
import { PATH_ADMIN_UPLOAD_BLOB } from '@/site/paths';
|
|
||||||
import { copy, del, list } from '@vercel/blob';
|
|
||||||
import { upload } from '@vercel/blob/client';
|
|
||||||
|
|
||||||
const STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
|
||||||
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
|
||||||
)?.[1].toLowerCase();
|
|
||||||
|
|
||||||
export const BLOB_BASE_URL =
|
|
||||||
`https://${STORE_ID}.public.blob.vercel-storage.com`;
|
|
||||||
|
|
||||||
const PREFIX_UPLOAD = 'upload';
|
|
||||||
const PREFIX_PHOTO = 'photo';
|
|
||||||
|
|
||||||
const REGEX_UPLOAD_PATH = new RegExp(
|
|
||||||
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
const REGEX_UPLOAD_ID = new RegExp(
|
|
||||||
`.${PREFIX_UPLOAD}-([a-z0-9]+)\.[a-z]{1,4}$`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
export const pathForBlobUrl = (url: string) =>
|
|
||||||
url.replace(`${BLOB_BASE_URL}/`, '');
|
|
||||||
|
|
||||||
export const getExtensionFromBlobUrl = (url: string) =>
|
|
||||||
url.match(/.([a-z]{1,4})$/i)?.[1];
|
|
||||||
|
|
||||||
export const getIdFromBlobUrl = (url: string) =>
|
|
||||||
url.match(REGEX_UPLOAD_ID)?.[1];
|
|
||||||
|
|
||||||
export const isUploadPathnameValid = (pathname?: string) =>
|
|
||||||
pathname?.match(REGEX_UPLOAD_PATH);
|
|
||||||
|
|
||||||
export const uploadPhotoFromClient = async (
|
|
||||||
file: File | Blob,
|
|
||||||
extension = 'jpg',
|
|
||||||
) =>
|
|
||||||
upload(
|
|
||||||
`${PREFIX_UPLOAD}.${extension}`,
|
|
||||||
file,
|
|
||||||
{
|
|
||||||
access: 'public',
|
|
||||||
handleUploadUrl: PATH_ADMIN_UPLOAD_BLOB,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const convertUploadToPhoto = async (
|
|
||||||
uploadUrl: string,
|
|
||||||
photoId?: string,
|
|
||||||
) => {
|
|
||||||
const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`;
|
|
||||||
const fileExtension = getExtensionFromBlobUrl(uploadUrl) ?? 'jpg';
|
|
||||||
const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`;
|
|
||||||
|
|
||||||
const { url } = await copy(
|
|
||||||
uploadUrl,
|
|
||||||
photoUrl,
|
|
||||||
{
|
|
||||||
access: 'public',
|
|
||||||
...photoId && { addRandomSuffix: false },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
await del(uploadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteBlobPhoto = (url: string) => del(url);
|
|
||||||
|
|
||||||
export const getBlobUploadUrls = () =>
|
|
||||||
list({ prefix: `${PREFIX_UPLOAD}-` })
|
|
||||||
.then(({ blobs }) => blobs.map(({ url }) => url));
|
|
||||||
|
|
||||||
export const getBlobPhotoUrls = () =>
|
|
||||||
list({ prefix: `${PREFIX_PHOTO}-` })
|
|
||||||
.then(({ blobs }) => blobs.map(({ url }) => url));
|
|
||||||
95
src/services/blob/aws-s3.ts
Normal file
95
src/services/blob/aws-s3.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { generateNanoid } from '@/utility/nanoid';
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
CopyObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
ListObjectsCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
const S3_BUCKET = process.env.NEXT_PUBLIC_S3_BUCKET ?? '';
|
||||||
|
const S3_REGION = process.env.NEXT_PUBLIC_S3_REGION ?? '';
|
||||||
|
const S3_UPLOAD_ACCESS_KEY =
|
||||||
|
process.env.NEXT_PUBLIC_S3_UPLOAD_ACCESS_KEY ?? '';
|
||||||
|
const S3_UPLOAD_SECRET_ACCESS_KEY =
|
||||||
|
process.env.NEXT_PUBLIC_S3_UPLOAD_SECRET_ACCESS_KEY ?? '';
|
||||||
|
const S3_ADMIN_ACCESS_KEY =
|
||||||
|
process.env.S3_ADMIN_ACCESS_KEY;
|
||||||
|
const S3_ADMIN_SECRET_ACCESS_KEY =
|
||||||
|
process.env.S3_ADMIN_SECRET_ACCESS_KEY;
|
||||||
|
|
||||||
|
export const HAS_AWS_S3_STORAGE =
|
||||||
|
S3_BUCKET.length > 0 &&
|
||||||
|
S3_REGION.length > 0 &&
|
||||||
|
S3_UPLOAD_ACCESS_KEY.length > 0 &&
|
||||||
|
S3_UPLOAD_SECRET_ACCESS_KEY.length > 0;
|
||||||
|
|
||||||
|
const client = () => new S3Client({
|
||||||
|
region: S3_REGION,
|
||||||
|
credentials: {
|
||||||
|
// Fall back on upload credentials if admin credentials are not available
|
||||||
|
accessKeyId: S3_ADMIN_ACCESS_KEY ?? S3_UPLOAD_ACCESS_KEY,
|
||||||
|
secretAccessKey: S3_ADMIN_SECRET_ACCESS_KEY ?? S3_UPLOAD_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AWS_S3_BASE_URL =
|
||||||
|
`https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com`;
|
||||||
|
|
||||||
|
export const isUrlFromAwsS3 = (url: string) =>
|
||||||
|
url.startsWith(AWS_S3_BASE_URL);
|
||||||
|
|
||||||
|
const urlForKey = (key?: string) => `${AWS_S3_BASE_URL}/${key}`;
|
||||||
|
|
||||||
|
const generateBlobId = () => generateNanoid(16);
|
||||||
|
|
||||||
|
export const awsS3UploadFromClient = async (
|
||||||
|
file: File | Blob,
|
||||||
|
fileName: string,
|
||||||
|
extension: string,
|
||||||
|
addRandomSuffix?: boolean,
|
||||||
|
) => {
|
||||||
|
const Key = addRandomSuffix
|
||||||
|
? `${fileName}-${generateBlobId()}.${extension}`
|
||||||
|
: `${fileName}.${extension}`;
|
||||||
|
return client().send(new PutObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key,
|
||||||
|
Body: file,
|
||||||
|
ACL: 'public-read',
|
||||||
|
}))
|
||||||
|
.then(() => urlForKey(Key));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const awsS3Copy = async (
|
||||||
|
fileNameSource: string,
|
||||||
|
fileNameDestination: string,
|
||||||
|
addRandomSuffix?: boolean,
|
||||||
|
) => {
|
||||||
|
const name = fileNameSource.split('.')[0];
|
||||||
|
const extension = fileNameSource.split('.')[1];
|
||||||
|
const Key = addRandomSuffix
|
||||||
|
? `${name}-${generateBlobId()}.${extension}`
|
||||||
|
: fileNameDestination;
|
||||||
|
return client().send(new CopyObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
CopySource: fileNameSource,
|
||||||
|
Key,
|
||||||
|
ACL: 'public-read',
|
||||||
|
}))
|
||||||
|
.then(() => urlForKey(fileNameDestination));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const awsS3Delete = async (Key: string) => {
|
||||||
|
client().send(new DeleteObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const awsS3List = async (Prefix: string) =>
|
||||||
|
client().send(new ListObjectsCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Prefix,
|
||||||
|
}))
|
||||||
|
.then((data) => data.Contents?.map(({ Key }) => urlForKey(Key)) ?? []);
|
||||||
86
src/services/blob/index.ts
Normal file
86
src/services/blob/index.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
VERCEL_BLOB_BASE_URL,
|
||||||
|
vercelBlobCopy,
|
||||||
|
vercelBlobDelete,
|
||||||
|
vercelBlobList,
|
||||||
|
vercelBlobUploadFromClient,
|
||||||
|
} from './vercel-blob';
|
||||||
|
import {
|
||||||
|
AWS_S3_BASE_URL,
|
||||||
|
HAS_AWS_S3_STORAGE,
|
||||||
|
awsS3Copy,
|
||||||
|
awsS3Delete,
|
||||||
|
awsS3List,
|
||||||
|
awsS3UploadFromClient,
|
||||||
|
isUrlFromAwsS3,
|
||||||
|
} from './aws-s3';
|
||||||
|
|
||||||
|
const PREFIX_UPLOAD = 'upload';
|
||||||
|
const PREFIX_PHOTO = 'photo';
|
||||||
|
const BLOB_BASE_URL = AWS_S3_BASE_URL ?? VERCEL_BLOB_BASE_URL;
|
||||||
|
|
||||||
|
const REGEX_UPLOAD_PATH = new RegExp(
|
||||||
|
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
const REGEX_UPLOAD_ID = new RegExp(
|
||||||
|
`.${PREFIX_UPLOAD}-([a-z0-9]+)\.[a-z]{1,4}$`,
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pathForBlobUrl = (url: string) =>
|
||||||
|
url.replace(`${BLOB_BASE_URL}/`, '');
|
||||||
|
|
||||||
|
export const getExtensionFromBlobUrl = (url: string) =>
|
||||||
|
url.match(/.([a-z]{1,4})$/i)?.[1];
|
||||||
|
|
||||||
|
export const getIdFromBlobUrl = (url: string) =>
|
||||||
|
url.match(REGEX_UPLOAD_ID)?.[1];
|
||||||
|
|
||||||
|
export const isUploadPathnameValid = (pathname?: string) =>
|
||||||
|
pathname?.match(REGEX_UPLOAD_PATH);
|
||||||
|
|
||||||
|
const getFileNameFromBlobUrl = (url: string) =>
|
||||||
|
(new URL(url).pathname.match(/\/(.+)$/)?.[1]) ?? '';
|
||||||
|
|
||||||
|
export const uploadPhotoFromClient = async (
|
||||||
|
file: File | Blob,
|
||||||
|
extension = 'jpg',
|
||||||
|
) => HAS_AWS_S3_STORAGE
|
||||||
|
? awsS3UploadFromClient(file, PREFIX_UPLOAD, extension, true)
|
||||||
|
: vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`);
|
||||||
|
|
||||||
|
export const convertUploadToPhoto = async (
|
||||||
|
uploadUrl: string,
|
||||||
|
photoId?: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`;
|
||||||
|
const fileExtension = getExtensionFromBlobUrl(uploadUrl);
|
||||||
|
const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`;
|
||||||
|
|
||||||
|
const url = await (HAS_AWS_S3_STORAGE
|
||||||
|
? awsS3Copy(uploadUrl, photoUrl, photoId === undefined)
|
||||||
|
: vercelBlobCopy(uploadUrl, photoUrl, photoId === undefined));
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
await (HAS_AWS_S3_STORAGE
|
||||||
|
? awsS3Delete(getFileNameFromBlobUrl(uploadUrl))
|
||||||
|
: vercelBlobDelete(uploadUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBlobUrl = (url: string) =>
|
||||||
|
HAS_AWS_S3_STORAGE && isUrlFromAwsS3(url)
|
||||||
|
? awsS3Delete(getFileNameFromBlobUrl(url))
|
||||||
|
: vercelBlobDelete(url);
|
||||||
|
|
||||||
|
export const getBlobUploadUrls = (): Promise<string[]> => HAS_AWS_S3_STORAGE
|
||||||
|
? awsS3List(`${PREFIX_UPLOAD}-`)
|
||||||
|
: vercelBlobList(`${PREFIX_UPLOAD}-`);
|
||||||
|
|
||||||
|
export const getBlobPhotoUrls = (): Promise<string[]> => HAS_AWS_S3_STORAGE
|
||||||
|
? awsS3List(`${PREFIX_PHOTO}-`)
|
||||||
|
: vercelBlobList(`${PREFIX_PHOTO}-`);
|
||||||
44
src/services/blob/vercel-blob.ts
Normal file
44
src/services/blob/vercel-blob.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { PATH_ADMIN_UPLOAD_BLOB } from '@/site/paths';
|
||||||
|
import { copy, del, list } from '@vercel/blob';
|
||||||
|
import { upload } from '@vercel/blob/client';
|
||||||
|
|
||||||
|
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
||||||
|
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
||||||
|
)?.[1].toLowerCase();
|
||||||
|
|
||||||
|
export const VERCEL_BLOB_BASE_URL =
|
||||||
|
`https://${VERCEL_BLOB_STORE_ID}.public.blob.vercel-storage.com`;
|
||||||
|
|
||||||
|
export const vercelBlobUploadFromClient = async (
|
||||||
|
file: File | Blob,
|
||||||
|
fileName: string,
|
||||||
|
) =>
|
||||||
|
upload(
|
||||||
|
fileName,
|
||||||
|
file,
|
||||||
|
{
|
||||||
|
access: 'public',
|
||||||
|
handleUploadUrl: PATH_ADMIN_UPLOAD_BLOB,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(({ url }) => url);
|
||||||
|
|
||||||
|
export const vercelBlobCopy = (
|
||||||
|
fileNameSource: string,
|
||||||
|
fileNameDestination: string,
|
||||||
|
addRandomSuffix?: boolean,
|
||||||
|
): Promise<string> =>
|
||||||
|
copy(
|
||||||
|
fileNameSource,
|
||||||
|
fileNameDestination,
|
||||||
|
{
|
||||||
|
access: 'public',
|
||||||
|
addRandomSuffix,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(({ url }) => url);
|
||||||
|
|
||||||
|
export const vercelBlobDelete = (fileName: string) => del(fileName);
|
||||||
|
|
||||||
|
export const vercelBlobList = (prefix: string) => list({ prefix })
|
||||||
|
.then(({ blobs }) => blobs.map(({ url }) => url));
|
||||||
@ -121,16 +121,30 @@ export default function SiteChecklistClient({
|
|||||||
and connect to project
|
and connect to project
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
<ChecklistRow
|
<ChecklistRow
|
||||||
title="Setup blob store"
|
title="Setup blob store (one of the following)"
|
||||||
status={hasBlob}
|
status={hasBlob}
|
||||||
isPending={isPendingPage}
|
isPending={isPendingPage}
|
||||||
>
|
>
|
||||||
{renderLink(
|
<ol className="list-decimal list-inside">
|
||||||
'https://vercel.com/docs/storage/vercel-blob/quickstart',
|
<li>
|
||||||
'Create Vercel Blob store',
|
Vercel Blob:
|
||||||
)}
|
{' '}
|
||||||
{' '}
|
{renderLink(
|
||||||
and connect to project
|
'https://vercel.com/docs/storage/vercel-blob/quickstart',
|
||||||
|
'create store',
|
||||||
|
)}
|
||||||
|
{' '}
|
||||||
|
and connect to project
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
AWS S3:
|
||||||
|
{' '}
|
||||||
|
{renderLink(
|
||||||
|
'https://github.com/sambecker/exif-photo-blog#aws-s3',
|
||||||
|
'create/configure bucket',
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
</Checklist>
|
</Checklist>
|
||||||
<Checklist
|
<Checklist
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
|
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
|
||||||
|
|
||||||
|
// META / DOMAINS
|
||||||
|
|
||||||
export const SITE_TITLE =
|
export const SITE_TITLE =
|
||||||
process.env.NEXT_PUBLIC_SITE_TITLE ||
|
process.env.NEXT_PUBLIC_SITE_TITLE ||
|
||||||
'Photo Blog';
|
'Photo Blog';
|
||||||
@ -28,6 +30,20 @@ export const BASE_URL = process.env.NODE_ENV === 'production'
|
|||||||
? makeUrlAbsolute(SITE_DOMAIN).toLowerCase()
|
? makeUrlAbsolute(SITE_DOMAIN).toLowerCase()
|
||||||
: 'http://localhost:3000';
|
: 'http://localhost:3000';
|
||||||
|
|
||||||
|
// STORAGE
|
||||||
|
|
||||||
|
const hasVercelBlob = (process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0;
|
||||||
|
const hasAwsS3Storage =
|
||||||
|
(process.env.NEXT_PUBLIC_S3_BUCKET ?? '').length > 0 &&
|
||||||
|
(process.env.NEXT_PUBLIC_S3_REGION ?? '').length > 0 &&
|
||||||
|
(process.env.NEXT_PUBLIC_S3_UPLOAD_ACCESS_KEY ?? '').length > 0 &&
|
||||||
|
(process.env.NEXT_PUBLIC_S3_UPLOAD_SECRET_ACCESS_KEY ?? '').length > 0 &&
|
||||||
|
(process.env.S3_ADMIN_ACCESS_KEY ?? '').length > 0 &&
|
||||||
|
(process.env.S3_ADMIN_SECRET_ACCESS_KEY ?? '').length > 0;
|
||||||
|
|
||||||
|
|
||||||
|
// SETTINGS
|
||||||
|
|
||||||
export const PRO_MODE_ENABLED = process.env.NEXT_PUBLIC_PRO_MODE === '1';
|
export const PRO_MODE_ENABLED = process.env.NEXT_PUBLIC_PRO_MODE === '1';
|
||||||
export const PUBLIC_API_ENABLED = process.env.NEXT_PUBLIC_PUBLIC_API === '1';
|
export const PUBLIC_API_ENABLED = process.env.NEXT_PUBLIC_PUBLIC_API === '1';
|
||||||
export const SHOW_REPO_LINK = process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
|
export const SHOW_REPO_LINK = process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
|
||||||
@ -38,7 +54,7 @@ export const OG_TEXT_BOTTOM_ALIGNMENT =
|
|||||||
|
|
||||||
export const CONFIG_CHECKLIST_STATUS = {
|
export const CONFIG_CHECKLIST_STATUS = {
|
||||||
hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
|
hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
|
||||||
hasBlob: (process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0,
|
hasBlob: hasVercelBlob || hasAwsS3Storage,
|
||||||
hasAuth: (process.env.AUTH_SECRET ?? '').length > 0,
|
hasAuth: (process.env.AUTH_SECRET ?? '').length > 0,
|
||||||
hasAdminUser: (
|
hasAdminUser: (
|
||||||
(process.env.ADMIN_EMAIL ?? '').length > 0 &&
|
(process.env.ADMIN_EMAIL ?? '').length > 0 &&
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user