Public File Sharing (#72)
* Public File Sharing This implements public file sharing (read-only) with and without passwords (#57). It also fixes a problem with filenames including special characters like `#` not working properly (#71). You can share a directory or a single file, by using the new share icon on the right of the directories/files, and click on it to manage an existing file share (setting a new password, or deleting the file share). There is some other minor cleanup and other copy updates in the README. Closes #57 Fixes #71 * Hide UI elements when sharing isn't allowed
This commit is contained in:
38
lib/auth.ts
38
lib/auth.ts
@@ -30,7 +30,7 @@ export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data);
|
||||
export const generateKey = async (key: string): Promise<CryptoKey> =>
|
||||
await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||
|
||||
async function signAuthJwt(key: CryptoKey, data: JwtData): Promise<string> {
|
||||
async function signAuthJwt<T = JwtData>(key: CryptoKey, data: T): Promise<string> {
|
||||
const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' +
|
||||
encodeBase64Url(textToData(JSON.stringify(data) || ''));
|
||||
const signature = encodeBase64Url(
|
||||
@@ -39,7 +39,7 @@ async function signAuthJwt(key: CryptoKey, data: JwtData): Promise<string> {
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise<JwtData> {
|
||||
export async function verifyAuthJwt<T = JwtData>(key: CryptoKey, jwt: string): Promise<T> {
|
||||
const jwtParts = jwt.split('.');
|
||||
if (jwtParts.length !== 3) {
|
||||
throw new Error('Malformed JWT');
|
||||
@@ -47,7 +47,7 @@ export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise<JwtDat
|
||||
|
||||
const data = textToData(jwtParts[0] + '.' + jwtParts[1]);
|
||||
if (await crypto.subtle.verify({ name: 'HMAC' }, key, decodeBase64Url(jwtParts[2]), data) === true) {
|
||||
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as JwtData;
|
||||
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as T;
|
||||
}
|
||||
|
||||
throw new Error('Invalid JWT');
|
||||
@@ -145,10 +145,10 @@ async function getDataFromCookie(
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateToken(tokenData: JwtData['data']): Promise<string> {
|
||||
export async function generateToken<T = JwtData>(tokenData: T): Promise<string> {
|
||||
const key = await generateKey(JWT_SECRET);
|
||||
|
||||
const token = await signAuthJwt(key, { data: tokenData });
|
||||
const token = await signAuthJwt<{ data: T }>(key, { data: tokenData });
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -243,31 +243,3 @@ export async function createSessionCookie(
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function updateSessionCookie(
|
||||
response: Response,
|
||||
request: Request,
|
||||
userSession: UserSession,
|
||||
newSessionData: JwtData['data'],
|
||||
) {
|
||||
const token = await generateToken(newSessionData);
|
||||
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
expires: userSession.expires_at,
|
||||
path: '/',
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
domain: await resolveCookieDomain(request),
|
||||
};
|
||||
|
||||
if (await AppConfig.isCookieDomainSecurityDisabled()) {
|
||||
delete cookie.domain;
|
||||
}
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export class AppConfig {
|
||||
},
|
||||
files: {
|
||||
rootPath: 'data-files',
|
||||
allowPublicSharing: false,
|
||||
},
|
||||
core: {
|
||||
enabledApps: ['news', 'notes', 'photos', 'expenses'],
|
||||
@@ -156,6 +157,12 @@ export class AppConfig {
|
||||
return this.config.auth.enableSingleSignOn;
|
||||
}
|
||||
|
||||
static async isPublicFileSharingAllowed(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.files.allowPublicSharing;
|
||||
}
|
||||
|
||||
static async getFilesRootPath(): Promise<string> {
|
||||
await this.loadConfig();
|
||||
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { join } from 'std/path/join.ts';
|
||||
import { resolve } from 'std/path/resolve.ts';
|
||||
import { lookup } from 'mrmime';
|
||||
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
|
||||
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
|
||||
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import {
|
||||
COOKIE_NAME as AUTH_COOKIE_NAME,
|
||||
generateKey,
|
||||
generateToken,
|
||||
JWT_SECRET,
|
||||
resolveCookieDomain,
|
||||
verifyAuthJwt,
|
||||
} from '/lib/auth.ts';
|
||||
import { isRunningLocally } from '/lib/utils/misc.ts';
|
||||
|
||||
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`;
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export class DirectoryModel {
|
||||
static async list(userId: string, path: string): Promise<Directory[]> {
|
||||
@@ -18,6 +33,10 @@ export class DirectoryModel {
|
||||
entry.isDirectory || entry.isSymlink
|
||||
);
|
||||
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, path)
|
||||
: [];
|
||||
|
||||
for (const entry of directoryEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
@@ -27,6 +46,7 @@ export class DirectoryModel {
|
||||
directory_name: entry.name,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
@@ -110,6 +130,10 @@ export class DirectoryModel {
|
||||
const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeDirectoryPath of matchingDirectories) {
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, relativeDirectoryPath)
|
||||
: [];
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, relativeDirectoryPath));
|
||||
let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const directoryName = relativeDirectoryPath.split('/').pop()!;
|
||||
@@ -124,6 +148,9 @@ export class DirectoryModel {
|
||||
directory_name: directoryName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
file_share_id: fileShares.find((fileShare) =>
|
||||
fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/`
|
||||
)?.id || null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
@@ -150,6 +177,10 @@ export class FileModel {
|
||||
|
||||
const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile);
|
||||
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, path)
|
||||
: [];
|
||||
|
||||
for (const entry of fileEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
@@ -159,6 +190,7 @@ export class FileModel {
|
||||
file_name: entry.name,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(path, entry.name))?.id || null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
@@ -315,6 +347,10 @@ export class FileModel {
|
||||
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeFilePath of matchingFiles) {
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, relativeFilePath)
|
||||
: [];
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, relativeFilePath));
|
||||
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const fileName = relativeFilePath.split('/').pop()!;
|
||||
@@ -329,6 +365,8 @@ export class FileModel {
|
||||
file_name: fileName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(relativeFilePath, fileName))?.id ||
|
||||
null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
@@ -384,6 +422,10 @@ export class FileModel {
|
||||
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeFilePath of matchingFiles) {
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, relativeFilePath)
|
||||
: [];
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, relativeFilePath));
|
||||
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const fileName = relativeFilePath.split('/').pop()!;
|
||||
@@ -398,6 +440,8 @@ export class FileModel {
|
||||
file_name: fileName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(relativeFilePath, fileName))?.id ||
|
||||
null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
@@ -414,6 +458,133 @@ export class FileModel {
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileShareJwtData {
|
||||
data: {
|
||||
file_share_id: string;
|
||||
hashed_password: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class FileShareModel {
|
||||
static async getById(id: string): Promise<FileShare | null> {
|
||||
const fileShare = (await db.query<FileShare>(sql`SELECT * FROM "bewcloud_file_shares" WHERE "id" = $1 LIMIT 1`, [
|
||||
id,
|
||||
]))[0];
|
||||
|
||||
return fileShare;
|
||||
}
|
||||
|
||||
static async getByParentFilePath(userId: string, parentFilePath: string): Promise<FileShare[]> {
|
||||
const fileShares = await db.query<FileShare>(
|
||||
sql`SELECT * FROM "bewcloud_file_shares" WHERE "user_id" = $1 AND "file_path" LIKE $2`,
|
||||
[userId, `${parentFilePath}%`],
|
||||
);
|
||||
|
||||
return fileShares;
|
||||
}
|
||||
|
||||
static async create(fileShare: Omit<FileShare, 'id' | 'created_at'>): Promise<FileShare> {
|
||||
const newFileShare = (await db.query<FileShare>(
|
||||
sql`INSERT INTO "bewcloud_file_shares" (
|
||||
"user_id",
|
||||
"file_path",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[
|
||||
fileShare.user_id,
|
||||
fileShare.file_path,
|
||||
JSON.stringify(fileShare.extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newFileShare;
|
||||
}
|
||||
|
||||
static async update(fileShare: FileShare): Promise<void> {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_file_shares" SET "extra" = $2 WHERE "id" = $1`,
|
||||
[fileShare.id, JSON.stringify(fileShare.extra)],
|
||||
);
|
||||
}
|
||||
|
||||
static async delete(fileShareId: string): Promise<void> {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_file_shares" WHERE "id" = $1`,
|
||||
[fileShareId],
|
||||
);
|
||||
}
|
||||
|
||||
static async createSessionCookie(
|
||||
request: Request,
|
||||
response: Response,
|
||||
fileShareId: string,
|
||||
hashedPassword: string,
|
||||
) {
|
||||
const token = await generateToken<FileShareJwtData['data']>({
|
||||
file_share_id: fileShareId,
|
||||
hashed_password: hashedPassword,
|
||||
});
|
||||
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
|
||||
path: `/file-share/${fileShareId}`,
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
domain: await resolveCookieDomain(request),
|
||||
};
|
||||
|
||||
if (await AppConfig.isCookieDomainSecurityDisabled()) {
|
||||
delete cookie.domain;
|
||||
}
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
static async getDataFromRequest(request: Request): Promise<{ fileShareId: string; hashedPassword: string } | null> {
|
||||
const cookies = getCookies(request.headers);
|
||||
|
||||
if (cookies[COOKIE_NAME]) {
|
||||
const result = await this.getDataFromCookie(cookies[COOKIE_NAME]);
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async getDataFromCookie(
|
||||
cookieValue: string,
|
||||
): Promise<{ fileShareId: string; hashedPassword: string } | null> {
|
||||
if (!cookieValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = await generateKey(JWT_SECRET);
|
||||
|
||||
try {
|
||||
const token = await verifyAuthJwt<FileShareJwtData>(key, cookieValue);
|
||||
|
||||
if (!token.data.file_share_id || !token.data.hashed_password) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
|
||||
return { fileShareId: token.data.file_share_id, hashedPassword: token.data.hashed_password };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the user path is valid and securely accessible (meaning it's not trying to access files outside of the user's root directory).
|
||||
* Does not check if the path exists.
|
||||
@@ -433,6 +604,34 @@ export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the file share path is valid and securely accessible (meaning it's not trying to access files outside of the file share's root directory).
|
||||
* Does not check if the path exists.
|
||||
*
|
||||
* @param userId - The user ID
|
||||
* @param fileSharePath - The file share path
|
||||
* @param path - The relative path (user-provided) to check
|
||||
*/
|
||||
export async function ensureFileSharePathIsValidAndSecurelyAccessible(
|
||||
userId: string,
|
||||
fileSharePath: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, fileSharePath);
|
||||
|
||||
const userRootPath = join(await AppConfig.getFilesRootPath(), userId, '/');
|
||||
|
||||
const fileShareRootPath = join(userRootPath, fileSharePath);
|
||||
|
||||
const fullPath = join(fileShareRootPath, path);
|
||||
|
||||
const resolvedFullPath = `${resolve(fullPath)}/`;
|
||||
|
||||
if (!resolvedFullPath.startsWith(fileShareRootPath)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
|
||||
|
||||
@@ -536,3 +735,16 @@ export async function searchFilesAndDirectories(
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPathInfo(userId: string, path: string): Promise<{ isDirectory: boolean; isFile: boolean }> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId);
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, path));
|
||||
|
||||
return {
|
||||
isDirectory: stat.isDirectory,
|
||||
isFile: stat.isFile,
|
||||
};
|
||||
}
|
||||
|
||||
14
lib/types.ts
14
lib/types.ts
@@ -93,6 +93,7 @@ export interface Directory {
|
||||
directory_name: string;
|
||||
has_write_access: boolean;
|
||||
size_in_bytes: number;
|
||||
file_share_id: string | null;
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -103,6 +104,7 @@ export interface DirectoryFile {
|
||||
file_name: string;
|
||||
has_write_access: boolean;
|
||||
size_in_bytes: number;
|
||||
file_share_id: string | null;
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -175,6 +177,8 @@ export interface Config {
|
||||
files: {
|
||||
/** The root-relative root path for files, i.e. "data-files" */
|
||||
rootPath: string;
|
||||
/** If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) */
|
||||
allowPublicSharing: boolean;
|
||||
};
|
||||
core: {
|
||||
/** dashboard and files cannot be disabled */
|
||||
@@ -221,3 +225,13 @@ export interface MultiFactorAuthMethod {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface FileShare {
|
||||
id: string;
|
||||
user_id: string;
|
||||
file_path: string;
|
||||
extra: {
|
||||
hashed_password?: string;
|
||||
};
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user