Skip to content

Commit

Permalink
Merge branch 'restrict-file-access' into hardcore-refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
pozylon committed Dec 17, 2024
2 parents 15d26e1 + 875bfb1 commit eaa7069
Show file tree
Hide file tree
Showing 24 changed files with 510 additions and 50 deletions.
3 changes: 3 additions & 0 deletions examples/kitchensink/src/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ const start = async () => {
}),
],
options: {
files: {
privateFileSharingMaxAge: 86400000,
},
payment: {
filterSupportedProviders: async ({ providers }) => {
return providers.sort((left, right) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/resolvers/mutations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export default {
heartbeat: acl(actions.heartbeat)(heartbeat),
addEmail: acl(actions.updateUser)(addEmail),
removeEmail: acl(actions.updateUser)(removeEmail),
prepareUserAvatarUpload: acl(actions.updateUser)(prepareUserAvatarUpload),
prepareUserAvatarUpload: acl(actions.uploadUserAvatar)(prepareUserAvatarUpload),
updateUserProfile: acl(actions.updateUser)(updateUserProfile),
removeUser: acl(actions.updateUser)(removeUser),
setUserTags: acl(actions.manageUsers)(setUserTags),
Expand Down
21 changes: 16 additions & 5 deletions packages/api/src/resolvers/type/media-types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { File as FileType } from '@unchainedshop/core-files';
import { File as FileType, getFileAdapter } from '@unchainedshop/core-files';
import { Context } from '../../context.js';

import { checkAction } from '../../acl.js';
import { actions } from '../../roles/index.js';
export interface MediaHelperTypes {
url: (language: FileType, params: Record<string, any>, context: Context) => string;
url: (language: FileType, params: Record<string, any>, context: Context) => Promise<string>;
}

export const Media: MediaHelperTypes = {
url(root, params, { modules }) {
return modules.files.getUrl(root, params);
url: async (file, params, context) => {
const { modules } = context;
try {
await checkAction(context, actions.downloadFile, [file, params]);
if (!file) return null;
const fileUploadAdapter = getFileAdapter();
const url = await fileUploadAdapter.createDownloadURL(file, params?.expires);
return modules.files.normalizeUrl(url, params);
} catch (e) {
console.error(e);
return null;
}
},
};
10 changes: 10 additions & 0 deletions packages/api/src/roles/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export const all = (role, actions) => {
return false;
};

const isFilePublic = async (file) => {
// Non private files or no files always resolve to true
if (!file?.meta?.isPrivate) return true;
return false;
};

role.allow(actions.viewEvent, () => false);
role.allow(actions.viewEvents, () => false);
role.allow(actions.viewUser, () => false);
Expand Down Expand Up @@ -93,6 +99,7 @@ export const all = (role, actions) => {
role.allow(actions.viewEnrollment, () => false);
role.allow(actions.viewTokens, () => false);
role.allow(actions.viewStatistics, () => false);
role.allow(actions.uploadUserAvatar, () => false);

// special case: when doing a login mutation, the user is not logged in technically yet,
// but should be able to see user data of the user that is about to be logged in
Expand All @@ -109,6 +116,9 @@ export const all = (role, actions) => {
role.allow(actions.updateToken, isOwnedToken);
role.allow(actions.viewToken, isOwnedToken);

// special case: access to file downloads should work when meta.isPrivate is not set
role.allow(actions.downloadFile, isFilePublic);

// only allow if query is not demanding for drafts or inactive item lists
role.allow(actions.viewProducts, (root, { includeDrafts }) => !includeDrafts);
role.allow(actions.viewAssortments, (root, { includeInactive }) => !includeInactive);
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/roles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ const actions: Record<string, string> = [
'confirmMediaUpload',
'viewStatistics',
'removeUser',
'downloadFile',
'uploadUserAvatar',
].reduce((oldValue, actionValue) => {
const newValue = oldValue;
newValue[actionValue] = actionValue;
Expand Down
22 changes: 22 additions & 0 deletions packages/api/src/roles/loggedIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export const loggedIn = (role: any, actions: Record<string, string>) => {
// user wants to access himself
return true;
};
const canUpdateAvatar = (_, params: { userId?: string } = {}, context: Context) => {
const isVerified = context?.user?.emails.some(({ verified }) => verified);
if (!isVerified) return false;
if (params?.userId) {
return params?.userId === context?.userId;
}
return true;
};

const isOwnedEmailAddress = (obj: any, params: { email?: string }, { user }: Context) => {
return user?.emails?.some(
Expand Down Expand Up @@ -193,6 +201,18 @@ export const loggedIn = (role: any, actions: Record<string, string>) => {
return credentials.userId === userId;
};

const isFileAccessible = async (file, _, context) => {
const user = context?.user;

// Non private files or no files always resolve to true
if (!file?.meta?.isPrivate) return true;

// If private file, only return true if owned
if (file?.meta?.userId === user?._id) return true;

return false;
};

role.allow(actions.viewUser, isMyself);
role.allow(actions.viewUserRoles, isMyself);
role.allow(actions.viewUserOrders, isMyself);
Expand Down Expand Up @@ -226,4 +246,6 @@ export const loggedIn = (role: any, actions: Record<string, string>) => {
role.allow(actions.registerPaymentCredentials, () => true);
role.allow(actions.managePaymentCredentials, isOwnedPaymentCredential);
role.allow(actions.confirmMediaUpload, () => true);
role.allow(actions.downloadFile, isFileAccessible);
role.allow(actions.uploadUserAvatar, canUpdateAvatar);
};
2 changes: 1 addition & 1 deletion packages/api/src/schema/types/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default [
name: String!
type: String!
size: Int!
url(version: String = "original", baseUrl: String): String!
url(version: String = "original", baseUrl: String): String
}
`,
];
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
mongodb,
ModuleInput,
} from '@unchainedshop/mongodb';
import { FileDirector } from '@unchainedshop/file-upload';
import { AssortmentMediaCollection } from '../db/AssortmentMediaCollection.js';
import { AssortmentMediaText, AssortmentMediaType } from '../types.js';

Expand Down Expand Up @@ -273,16 +272,3 @@ export const configureAssortmentMediaModule = async ({
},
};
};

FileDirector.registerFileUploadCallback<{
modules: {
assortments: {
media: AssortmentMediaModule;
};
};
}>('assortment-media', async (file, { modules }) => {
await modules.assortments.media.create({
assortmentId: file.meta.assortmentId as string,
mediaId: file._id,
});
});
6 changes: 5 additions & 1 deletion packages/core-files/src/files-settings.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
export interface FilesSettingsOptions {
transformUrl?: (url: string, params: Record<string, any>) => string;
privateFileSharingMaxAge?: number;
}

export interface FilesSettings {
transformUrl?: (url: string, params: Record<string, any>) => string;
configureSettings: (options?: FilesSettingsOptions) => void;
privateFileSharingMaxAge?: number;
}

export const defaultTransformUrl = (url) => url;

export const filesSettings: FilesSettings = {
transformUrl: null,
configureSettings: async ({ transformUrl }) => {
privateFileSharingMaxAge: null,
configureSettings: async ({ transformUrl, privateFileSharingMaxAge }) => {
filesSettings.transformUrl = transformUrl || defaultTransformUrl;
filesSettings.privateFileSharingMaxAge = privateFileSharingMaxAge;
},
};
5 changes: 2 additions & 3 deletions packages/core-files/src/module/configureFilesModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ export const configureFilesModule = async ({
const Files = await MediaObjectsCollection(db);

return {
getUrl: (file: File, params: Record<string, any>): string | null => {
if (!file?.url) return null;
const transformedURLString = filesSettings.transformUrl(file.url, params);
normalizeUrl: (url: string, params: Record<string, any>): string => {
const transformedURLString = filesSettings.transformUrl(url, params);
if (URL.canParse(transformedURLString)) {
const finalURL = new URL(transformedURLString);
return finalURL.href;
Expand Down
14 changes: 0 additions & 14 deletions packages/core-products/src/module/configureProductMediaModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
generateDbObjectId,
mongodb,
} from '@unchainedshop/mongodb';
import { FileDirector } from '@unchainedshop/file-upload';
import { ProductMedia, ProductMediaCollection, ProductMediaText } from '../db/ProductMediaCollection.js';

export type ProductMediaModule = {
Expand Down Expand Up @@ -279,16 +278,3 @@ export const configureProductMediaModule = async ({
},
};
};

FileDirector.registerFileUploadCallback<{
modules: {
products: {
media: ProductMediaModule;
};
};
}>('product-media', async (file, { modules }) => {
await modules.products.media.create({
productId: file.meta?.productId as string,
mediaId: file._id,
});
});
4 changes: 4 additions & 0 deletions packages/file-upload/src/director/FileAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IBaseAdapter } from '@unchainedshop/utils';
import { UploadedFile, UploadFileData } from '../types.js';

export interface IFileAdapter<Context = unknown> extends IBaseAdapter {
createDownloadURL: (file: UploadedFile, expiry?: number) => Promise<string | null>;
createSignedURL: (
directoryName: string,
fileName: string,
Expand All @@ -28,6 +29,9 @@ export interface IFileAdapter<Context = unknown> extends IBaseAdapter {
createDownloadStream: (file: UploadedFile, unchainedAPI: Context) => Promise<Readable>;
}
export const FileAdapter: Omit<IFileAdapter, 'key' | 'label' | 'version'> = {
async createDownloadURL() {
throw new Error('Method not implemented');
},
createSignedURL() {
return new Promise<null>((resolve) => {
resolve(null);
Expand Down
19 changes: 16 additions & 3 deletions packages/platform/src/setup/setupUploadHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { UnchainedCore } from '@unchainedshop/core';
import { FileDirector } from '@unchainedshop/file-upload';

export const setupUploadHandlers = () => {
FileDirector.registerFileUploadCallback<UnchainedCore>('user-avatars', async (file, context) => {
const { services } = context;
export const setupUploadHandlers = ({ services, modules }: UnchainedCore) => {
FileDirector.registerFileUploadCallback('user-avatars', async (file) => {
return services.users.updateUserAvatarAfterUpload({ file });
});

FileDirector.registerFileUploadCallback('product-media', async (file) => {
await modules.products.media.create({
productId: file.meta?.productId as string,
mediaId: file._id,
});
});

FileDirector.registerFileUploadCallback('assortment-media', async (file) => {
await modules.assortments.media.create({
assortmentId: file.meta.assortmentId as string,
mediaId: file._id,
});
});
};
2 changes: 1 addition & 1 deletion packages/platform/src/startPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const startPlatform = async ({
setupTemplates(unchainedAPI);

// Setup file upload handlers
setupUploadHandlers();
setupUploadHandlers(unchainedAPI);

// Start the graphQL server
const graphqlHandler = await startAPIServer({
Expand Down
11 changes: 11 additions & 0 deletions packages/plugins/src/files/gridfs/gridfs-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@unchainedshop/file-upload';
import { UploadFileData } from '@unchainedshop/file-upload';
import sign from './sign.js';
import { filesSettings } from '@unchainedshop/core-files';

const { ROOT_URL } = process.env;

Expand All @@ -28,7 +29,17 @@ export const GridFSAdapter: IFileAdapter = {
version: '1.0.0',

...FileAdapter,
async createDownloadURL(file, expiry) {
// If public, just return the stored path from the db
if (!file.meta?.isPrivate) return file?.url;

const expiryTimestamp =
expiry ||
new Date(new Date().getTime() + (filesSettings?.privateFileSharingMaxAge || 0)).getTime();

const signature = await sign(file.path, file._id, expiryTimestamp);
return `${file.url}?s=${signature}&e=${expiryTimestamp}`;
},
async createSignedURL(directoryName, fileName) {
const expiryDate = resolveExpirationDate();
const hashedFilename = await buildHashedFilename(directoryName, fileName, expiryDate);
Expand Down
22 changes: 20 additions & 2 deletions packages/plugins/src/files/gridfs/gridfs-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import sign from './sign.js';
import { configureGridFSFileUploadModule } from './index.js';
import { Context } from '@unchainedshop/api';
import { createLogger } from '@unchainedshop/logger';
import { getFileAdapter } from '@unchainedshop/core-files';

const { ROOT_URL } = process.env;

Expand Down Expand Up @@ -49,7 +50,6 @@ export const gridfsHandler = async (
res.end('File already linked');
return;
}

// If the type is octet-stream, prefer mimetype lookup from the filename
// Else prefer the content-type header
const type =
Expand Down Expand Up @@ -79,8 +79,26 @@ export const gridfsHandler = async (

if (req.method === 'GET') {
const fileId = fileName;

const { s: signature, e: expiryTimestamp } = req.query;
const file = await modules.gridfsFileUploads.getFileInfo(directoryName, fileId);
const fileDocument = await modules.files.findFile({ fileId });
if (fileDocument?.meta?.isPrivate) {
const expiry = parseInt(expiryTimestamp as string, 10);
if (expiry <= Date.now()) {
res.statusCode = 403;
res.end('Access restricted: Expired.');
return;
}

const fileUploadAdapter = getFileAdapter();
const signedUrl = await fileUploadAdapter.createDownloadURL(fileDocument, expiry);

if (new URL(signedUrl, 'file://').searchParams.get('s') !== signature) {
res.statusCode = 403;
res.end('Access restricted: Invalid signature.');
return;
}
}
if (file?.metadata?.['content-type']) {
res.setHeader('Content-Type', file.metadata['content-type']);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/src/files/gridfs/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const { UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET } = process.env;
const sign = async (directoryName, hash, expiryTimestamp) => {
if (!UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET)
throw new Error(
'To enable PUT based uploads you have to provide a random UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET environment variable',
'To enable PUT based uploads or signed downloads you have to provide a random UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET environment variable',
);

const key = await crypto.subtle.importKey(
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/src/files/minio/minio-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ export const MinioAdapter: IFileAdapter = {

...FileAdapter,

async createDownloadURL(file) {
if (file.meta?.isPrivate) throw new Error("Minio Plugin doesn't support private files yet");
return generateMinioUrl(file.name, file._id);
},

async createSignedURL(directoryName, fileName) {
if (!client) throw new Error('Minio not connected, check env variables');

Expand Down
6 changes: 5 additions & 1 deletion packages/plugins/src/warehousing/eth-minter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { WarehousingProviderType } from '@unchainedshop/core-warehousing';
import { ProductContractStandard, ProductTypes } from '@unchainedshop/core-products';
import { systemLocale } from '@unchainedshop/utils';
import { generateDbObjectId } from '@unchainedshop/mongodb';
import { getFileAdapter } from '@unchainedshop/core-files';

const { MINTER_TOKEN_OFFSET = '0' } = process.env;

Expand Down Expand Up @@ -112,7 +113,10 @@ const ETHMinter: IWarehousingAdapter = {
limit: 1,
});
const file = firstMedia && (await modules.files.findFile({ fileId: firstMedia.mediaId }));
const url = file && (await modules.files.getUrl(file, {}));

const fileUploadAdapter = getFileAdapter();
const signedUrl = await fileUploadAdapter.createDownloadURL(file);
const url = signedUrl && (await modules.files.normalizeUrl(signedUrl, {}));
const text = await modules.products.texts.findLocalizedText({
productId: product._id,
locale: locale?.baseName || systemLocale.baseName,
Expand Down
Loading

0 comments on commit eaa7069

Please sign in to comment.