mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(server): type the email module and fix narration audio serving
Three defects, found while planning the Android client: - The email module's providers, preferences, templates, recipient groups, tests and send requests had no types in packages/types, so every client mirrored them from server code. They are typed now, from the JSON the routes return and accept, and the services and DTOs declare them. No response changes. - Narration audio paths were decoded twice: the router decodes the wildcard, and the route decoded it again, so a name holding a literal % resolved to the wrong entry or none. A path is now decoded once and matched to the playlist's hrefs exactly, with an exact entry name preferred. Each request also read the whole audio entry into memory; it is now streamed, read from its offset when stored and inflated and skipped when deflated, with the same headers and Range handling, and an open range at or past the end is a 416. Both narration audio routes skip response compression, which dropped Content-Length. - A playlist's section starts left out any clip of unknown length. They now use the media:duration each overlay declares when a clip's own length is unknown. A seq that interleaves par and seq children was reordered by the XML parser's grouping; SMIL is now read in document order.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { BookSelectionPayload } from "./book-selection";
|
||||
import type { DeliveryArtifactMetadata, ReadAloudDeliveryPreference } from "./delivery";
|
||||
|
||||
export interface EmailRecipient {
|
||||
@@ -76,3 +77,194 @@ export interface EmailSendLogSummary {
|
||||
failed: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
/** Every provider route answers with the stored row minus its password. */
|
||||
export interface EmailProvider {
|
||||
id: number;
|
||||
/** Null for a provider no account owns. */
|
||||
userId: number | null;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string | null;
|
||||
fromName: string | null;
|
||||
fromAddress: string | null;
|
||||
auth: boolean;
|
||||
ssl: boolean;
|
||||
startTls: boolean;
|
||||
isDefault: boolean;
|
||||
isShared: boolean;
|
||||
isSystemProvider: boolean;
|
||||
tlsRejectUnauthorized: boolean;
|
||||
/** Set by the last connection test; absent from servers before 3.4.0. */
|
||||
lastTestedAt?: string | null;
|
||||
lastTestOk?: boolean | null;
|
||||
lastTestError?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
/** POST /email/providers */
|
||||
export interface EmailProviderCreateRequest {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
fromName?: string;
|
||||
fromAddress?: string;
|
||||
auth: boolean;
|
||||
ssl: boolean;
|
||||
startTls: boolean;
|
||||
tlsRejectUnauthorized: boolean;
|
||||
}
|
||||
|
||||
/** PUT /email/providers/:id. An empty password removes the stored one. */
|
||||
export type EmailProviderUpdateRequest = Partial<EmailProviderCreateRequest>;
|
||||
|
||||
/** POST /email/providers/:id/test and POST /email/recipients/:id/test. */
|
||||
export interface EmailTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET and PUT /email/preferences: the defaults sending uses, including any still carried by a
|
||||
* row's legacy isDefault flag. An account that never saved preferences gets only userId and the
|
||||
* three defaults.
|
||||
*/
|
||||
export interface EmailPreferences {
|
||||
id?: number;
|
||||
userId: number;
|
||||
defaultProviderId: number | null;
|
||||
defaultRecipientId: number | null;
|
||||
defaultTemplateId: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** PUT /email/preferences. An omitted default is left alone; null clears it. */
|
||||
export interface EmailPreferencesUpdateRequest {
|
||||
defaultProviderId?: number | null;
|
||||
defaultRecipientId?: number | null;
|
||||
defaultTemplateId?: number | null;
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: number;
|
||||
/** Null for a built-in template. */
|
||||
userId: number | null;
|
||||
name: string;
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
isDefault: boolean;
|
||||
isSystem: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** POST /email/templates */
|
||||
export interface EmailTemplateCreateRequest {
|
||||
name: string;
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
/** PUT /email/templates/:id */
|
||||
export type EmailTemplateUpdateRequest = Partial<EmailTemplateCreateRequest>;
|
||||
|
||||
/** POST /email/templates/:id/preview */
|
||||
export interface EmailTemplatePreviewRequest {
|
||||
bookId: number;
|
||||
}
|
||||
|
||||
/** POST /email/templates/preview: text from the editor, before it is saved. */
|
||||
export interface EmailTemplateTextPreviewRequest {
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
bookId: number;
|
||||
}
|
||||
|
||||
/** POST /email/templates/:id/preview */
|
||||
export interface EmailTemplatePreview {
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
/** The file a send of the previewed book would attach, named as the email names it. */
|
||||
export interface EmailTemplatePreviewAttachment {
|
||||
fileName: string;
|
||||
format: string | null;
|
||||
sizeBytes: number | null;
|
||||
}
|
||||
|
||||
/** POST /email/templates/preview */
|
||||
export interface EmailTemplateTextPreview extends EmailTemplatePreview {
|
||||
/** Null when the book has no file a send could attach; absent from servers before 3.4.0. */
|
||||
attachment?: EmailTemplatePreviewAttachment | null;
|
||||
}
|
||||
|
||||
/** PUT /email/recipient-groups/:id answers this, without the members. */
|
||||
export interface EmailRecipientGroupSummary {
|
||||
id: number;
|
||||
userId: number;
|
||||
name: string;
|
||||
defaultTemplateId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Every other recipient group route that answers with a group. */
|
||||
export interface EmailRecipientGroup extends EmailRecipientGroupSummary {
|
||||
members: EmailRecipient[];
|
||||
}
|
||||
|
||||
/** POST /email/recipient-groups */
|
||||
export interface EmailRecipientGroupCreateRequest {
|
||||
name: string;
|
||||
defaultTemplateId?: number;
|
||||
}
|
||||
|
||||
/** PUT /email/recipient-groups/:id */
|
||||
export interface EmailRecipientGroupUpdateRequest {
|
||||
name?: string;
|
||||
defaultTemplateId?: number | null;
|
||||
}
|
||||
|
||||
/** POST /email/recipient-groups/:id/members */
|
||||
export interface EmailRecipientGroupMemberRequest {
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
/** POST /email/recipients/:id/test, written in the sender's interface language. */
|
||||
export interface EmailRecipientTestRequest {
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Who and how to send, beside the book selection of POST /email/send. */
|
||||
export interface EmailSendOptions {
|
||||
recipientIds?: number[];
|
||||
groupIds?: number[];
|
||||
fileId?: number;
|
||||
providerId?: number;
|
||||
templateId?: number;
|
||||
}
|
||||
|
||||
/** POST /email/send. It needs at least one recipient, given directly or through a group. */
|
||||
export type EmailSendRequest = BookSelectionPayload & EmailSendOptions;
|
||||
|
||||
/** POST /email/send, POST /email/send/quick/:bookId and POST /email/log/:id/resend. */
|
||||
export interface EmailSendAccepted {
|
||||
queued: number;
|
||||
}
|
||||
|
||||
/** The query of GET /email/log; GET /email/admin/log reads only page and size. */
|
||||
export interface EmailSendLogQuery {
|
||||
page?: number;
|
||||
size?: number;
|
||||
bookId?: number;
|
||||
status?: EmailSendLogEntry["status"];
|
||||
recipientId?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* A shared response type as a service builds it: the named fields still hold the Dates the
|
||||
* database returns, which the JSON response writes as the ISO strings the shared type declares.
|
||||
* It lets a service declare the response contract without converting rows it returns as they are.
|
||||
*/
|
||||
export type Unserialized<T, DateField extends keyof T> = { [K in keyof T]: K extends DateField ? DateOf<T[K]> : T[K] };
|
||||
|
||||
type DateOf<V> = V extends string ? Date : V;
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailRecipientGroupMemberRequest } from '@bookorbit/types';
|
||||
import { IsInt } from 'class-validator';
|
||||
|
||||
export class AddGroupMemberDto {
|
||||
export class AddGroupMemberDto implements EmailRecipientGroupMemberRequest {
|
||||
@IsInt()
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailProviderCreateRequest } from '@bookorbit/types';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateEmailProviderDto {
|
||||
export class CreateEmailProviderDto implements EmailProviderCreateRequest {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailRecipientGroupCreateRequest } from '@bookorbit/types';
|
||||
import { IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateEmailRecipientGroupDto {
|
||||
export class CreateEmailRecipientGroupDto implements EmailRecipientGroupCreateRequest {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailTemplateCreateRequest } from '@bookorbit/types';
|
||||
import { IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateEmailTemplateDto {
|
||||
export class CreateEmailTemplateDto implements EmailTemplateCreateRequest {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { EmailTemplateTextPreviewRequest } from '@bookorbit/types';
|
||||
import { IsInt, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** A template that has not been saved yet, rendered against one book for the editor's preview. */
|
||||
export class PreviewTemplateTextDto {
|
||||
export class PreviewTemplateTextDto implements EmailTemplateTextPreviewRequest {
|
||||
@IsString()
|
||||
@MaxLength(998)
|
||||
subject: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailTemplatePreviewRequest } from '@bookorbit/types';
|
||||
import { IsInt } from 'class-validator';
|
||||
|
||||
export class PreviewTemplateDto {
|
||||
export class PreviewTemplateDto implements EmailTemplatePreviewRequest {
|
||||
@IsInt()
|
||||
bookId: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { EmailSendLogQuery } from '@bookorbit/types';
|
||||
import { IsIn, IsInt, IsOptional, IsPositive, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class QuerySendLogDto {
|
||||
export class QuerySendLogDto implements EmailSendLogQuery {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { EmailSendOptions } from '@bookorbit/types';
|
||||
import { IsArray, IsInt, IsOptional } from 'class-validator';
|
||||
|
||||
import { BulkSelectionDto } from '../../../common/dto/bulk-selection.dto';
|
||||
|
||||
export class SendBookDto extends BulkSelectionDto {
|
||||
export class SendBookDto extends BulkSelectionDto implements EmailSendOptions {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { EmailRecipientTestRequest } from '@bookorbit/types';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* The test message comes from the client so it is written in the sender's interface language;
|
||||
* the server has no catalog for email copy.
|
||||
*/
|
||||
export class SendRecipientTestDto {
|
||||
export class SendRecipientTestDto implements EmailRecipientTestRequest {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailPreferencesUpdateRequest } from '@bookorbit/types';
|
||||
import { IsInt, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateEmailPreferencesDto {
|
||||
export class UpdateEmailPreferencesDto implements EmailPreferencesUpdateRequest {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
defaultProviderId?: number | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailProviderUpdateRequest } from '@bookorbit/types';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class UpdateEmailProviderDto {
|
||||
export class UpdateEmailProviderDto implements EmailProviderUpdateRequest {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailRecipientGroupUpdateRequest } from '@bookorbit/types';
|
||||
import { IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateEmailRecipientGroupDto {
|
||||
export class UpdateEmailRecipientGroupDto implements EmailRecipientGroupUpdateRequest {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EmailTemplateUpdateRequest } from '@bookorbit/types';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateEmailTemplateDto {
|
||||
export class UpdateEmailTemplateDto implements EmailTemplateUpdateRequest {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { EmailPreferences } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { EmailPreferences } from '../../db/schema';
|
||||
import type { Unserialized } from '../../common/types/unserialized';
|
||||
import { EmailPreferencesRepository } from './email-preferences.repository';
|
||||
import { EmailProviderService } from './email-provider.service';
|
||||
import { EmailRecipientService } from './email-recipient.service';
|
||||
@@ -9,8 +10,7 @@ import { EmailTemplateService } from './email-template.service';
|
||||
import { UpdateEmailPreferencesDto } from './dto/update-email-preferences.dto';
|
||||
|
||||
/** The stored row when there is one, with every default replaced by the one sending will use. */
|
||||
export type EffectiveEmailPreferences = Pick<EmailPreferences, 'userId' | 'defaultProviderId' | 'defaultRecipientId' | 'defaultTemplateId'> &
|
||||
Partial<Pick<EmailPreferences, 'id' | 'createdAt' | 'updatedAt'>>;
|
||||
export type EffectiveEmailPreferences = Unserialized<EmailPreferences, 'createdAt' | 'updatedAt'>;
|
||||
|
||||
@Injectable()
|
||||
export class EmailPreferencesService {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import type { EmailProvider as EmailProviderJson, EmailTestResult } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { Unserialized } from '../../common/types/unserialized';
|
||||
import { emailProviders } from '../../db/schema';
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
@@ -12,6 +14,8 @@ import { UpdateEmailProviderDto } from './dto/update-email-provider.dto';
|
||||
import type { EmailProvider } from '../../db/schema';
|
||||
import { isUniqueViolation } from './email-db-error.util';
|
||||
|
||||
type EmailProviderResponse = Unserialized<EmailProviderJson, 'createdAt' | 'updatedAt' | 'lastTestedAt'>;
|
||||
|
||||
@Injectable()
|
||||
export class EmailProviderService {
|
||||
constructor(
|
||||
@@ -21,17 +25,17 @@ export class EmailProviderService {
|
||||
private readonly preferences: EmailPreferencesRepository,
|
||||
) {}
|
||||
|
||||
async findAll(user: RequestUser) {
|
||||
async findAll(user: RequestUser): Promise<EmailProviderResponse[]> {
|
||||
const rows = await this.repo.findAllForUser(user.id);
|
||||
return rows.map((p) => this.sanitize(p));
|
||||
}
|
||||
|
||||
async findOne(id: number, user: RequestUser) {
|
||||
async findOne(id: number, user: RequestUser): Promise<EmailProviderResponse> {
|
||||
const provider = await this.getOwnedOrShared(id, user);
|
||||
return this.sanitize(provider);
|
||||
}
|
||||
|
||||
async create(dto: CreateEmailProviderDto, user: RequestUser) {
|
||||
async create(dto: CreateEmailProviderDto, user: RequestUser): Promise<EmailProviderResponse> {
|
||||
const passwordEnc = dto.password ? this.encryption.encrypt(dto.password) : null;
|
||||
try {
|
||||
const [created] = await this.repo.insert({
|
||||
@@ -57,7 +61,7 @@ export class EmailProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateEmailProviderDto, user: RequestUser) {
|
||||
async update(id: number, dto: UpdateEmailProviderDto, user: RequestUser): Promise<EmailProviderResponse> {
|
||||
await this.getOwned(id, user);
|
||||
|
||||
const patch: Partial<typeof emailProviders.$inferInsert> = {};
|
||||
@@ -95,7 +99,7 @@ export class EmailProviderService {
|
||||
await this.repo.delete(id, user.id);
|
||||
}
|
||||
|
||||
async setDefault(id: number, user: RequestUser) {
|
||||
async setDefault(id: number, user: RequestUser): Promise<EmailProviderResponse> {
|
||||
await this.getOwned(id, user);
|
||||
await this.preferences.setDefaults(user.id, { defaultProviderId: id });
|
||||
const [updated] = await this.repo.findById(id);
|
||||
@@ -103,7 +107,7 @@ export class EmailProviderService {
|
||||
return this.sanitize(updated);
|
||||
}
|
||||
|
||||
async toggleShared(id: number, user: RequestUser) {
|
||||
async toggleShared(id: number, user: RequestUser): Promise<EmailProviderResponse> {
|
||||
if (!user.isSuperuser && !user.permissions.includes(Permission.ManageEmail)) {
|
||||
throw new ForbiddenException(`Missing permission: ${Permission.ManageEmail}`);
|
||||
}
|
||||
@@ -113,7 +117,7 @@ export class EmailProviderService {
|
||||
return this.sanitize(updated);
|
||||
}
|
||||
|
||||
async setSystemProvider(id: number, user: RequestUser) {
|
||||
async setSystemProvider(id: number, user: RequestUser): Promise<EmailProviderResponse> {
|
||||
if (!user.isSuperuser) throw new ForbiddenException('Only superusers can configure the system mail provider');
|
||||
await this.getOwnedOrShared(id, user);
|
||||
const updated = await this.repo.setSystemProvider(id);
|
||||
@@ -126,7 +130,7 @@ export class EmailProviderService {
|
||||
await this.repo.clearSystemProvider();
|
||||
}
|
||||
|
||||
async testConnection(id: number, user: RequestUser): Promise<{ success: boolean; error?: string }> {
|
||||
async testConnection(id: number, user: RequestUser): Promise<EmailTestResult> {
|
||||
const provider = await this.getOwnedOrShared(id, user);
|
||||
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
|
||||
|
||||
@@ -141,7 +145,7 @@ export class EmailProviderService {
|
||||
tlsRejectUnauthorized: provider.tlsRejectUnauthorized,
|
||||
});
|
||||
|
||||
let result: { success: boolean; error?: string };
|
||||
let result: EmailTestResult;
|
||||
try {
|
||||
await this.transport.verifyTransporter(transporter);
|
||||
result = { success: true };
|
||||
@@ -174,7 +178,7 @@ export class EmailProviderService {
|
||||
return provider;
|
||||
}
|
||||
|
||||
private sanitize(provider: EmailProvider) {
|
||||
private sanitize(provider: EmailProvider): EmailProviderResponse {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordEnc, ...rest } = provider;
|
||||
return { ...rest, hasPassword: !!provider.passwordEnc };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { EmailRecipient, EmailRecipientGroup as EmailRecipientGroupJson, EmailRecipientGroupSummary } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { Unserialized } from '../../common/types/unserialized';
|
||||
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
|
||||
import { EmailRecipientRepository } from './email-recipient.repository';
|
||||
import { EmailTemplateService } from './email-template.service';
|
||||
@@ -9,6 +11,11 @@ import { UpdateEmailRecipientGroupDto } from './dto/update-email-recipient-group
|
||||
import type { EmailRecipientGroup } from '../../db/schema';
|
||||
import { isUniqueViolation } from './email-db-error.util';
|
||||
|
||||
type EmailRecipientGroupSummaryResponse = Unserialized<EmailRecipientGroupSummary, 'createdAt' | 'updatedAt'>;
|
||||
type EmailRecipientGroupResponse = Unserialized<Omit<EmailRecipientGroupJson, 'members'>, 'createdAt' | 'updatedAt'> & {
|
||||
members: Unserialized<EmailRecipient, 'createdAt'>[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EmailRecipientGroupService {
|
||||
constructor(
|
||||
@@ -17,7 +24,7 @@ export class EmailRecipientGroupService {
|
||||
private readonly templateService: EmailTemplateService,
|
||||
) {}
|
||||
|
||||
async findAll(user: RequestUser) {
|
||||
async findAll(user: RequestUser): Promise<EmailRecipientGroupResponse[]> {
|
||||
const groups = await this.repo.findAllForUser(user.id);
|
||||
if (groups.length === 0) return [];
|
||||
|
||||
@@ -38,13 +45,13 @@ export class EmailRecipientGroupService {
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number, user: RequestUser) {
|
||||
async findOne(id: number, user: RequestUser): Promise<EmailRecipientGroupResponse> {
|
||||
const group = await this.getOwned(id, user);
|
||||
const memberRows = await this.repo.findMembers(group.id);
|
||||
return { ...group, members: memberRows.map((r) => r.recipient) };
|
||||
}
|
||||
|
||||
async create(dto: CreateEmailRecipientGroupDto, user: RequestUser) {
|
||||
async create(dto: CreateEmailRecipientGroupDto, user: RequestUser): Promise<EmailRecipientGroupResponse> {
|
||||
await this.validateDefaultTemplate(dto.defaultTemplateId, user);
|
||||
try {
|
||||
const [created] = await this.repo.insert({
|
||||
@@ -61,7 +68,7 @@ export class EmailRecipientGroupService {
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateEmailRecipientGroupDto, user: RequestUser) {
|
||||
async update(id: number, dto: UpdateEmailRecipientGroupDto, user: RequestUser): Promise<EmailRecipientGroupSummaryResponse> {
|
||||
await this.getOwned(id, user);
|
||||
await this.validateDefaultTemplate(dto.defaultTemplateId, user);
|
||||
try {
|
||||
@@ -81,7 +88,7 @@ export class EmailRecipientGroupService {
|
||||
await this.repo.delete(id, user.id);
|
||||
}
|
||||
|
||||
async addMember(groupId: number, recipientId: number, user: RequestUser) {
|
||||
async addMember(groupId: number, recipientId: number, user: RequestUser): Promise<EmailRecipientGroupResponse> {
|
||||
await this.getOwned(groupId, user);
|
||||
const [recipient] = await this.recipientRepo.findById(recipientId);
|
||||
if (!recipient) throw new NotFoundException('Recipient not found');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { EmailTestResult } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { EmailProviderResolver } from './email-provider-resolver';
|
||||
@@ -22,7 +24,7 @@ export class EmailRecipientTestService {
|
||||
private readonly transport: EmailTransportService,
|
||||
) {}
|
||||
|
||||
async send(recipientId: number, subject: string, text: string, user: RequestUser): Promise<{ success: boolean; error?: string }> {
|
||||
async send(recipientId: number, subject: string, text: string, user: RequestUser): Promise<EmailTestResult> {
|
||||
const startedAt = Date.now();
|
||||
const recipient = await this.recipientService.getOwnedById(recipientId, user);
|
||||
// Amazon answers a message without a document with an error email of its own.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ForbiddenException, BadRequestException, Injectable, Logger, Optional }
|
||||
import type { Readable } from 'stream';
|
||||
import { createReadStream } from 'fs';
|
||||
import { openDeliveryStream } from '../book/delivery-stream';
|
||||
import type { DeliveryArtifactMetadata } from '@bookorbit/types';
|
||||
import type { DeliveryArtifactMetadata, EmailSendAccepted } from '@bookorbit/types';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -73,7 +73,7 @@ export class EmailSendOrchestrator {
|
||||
@Optional() private readonly users?: UserService,
|
||||
) {}
|
||||
|
||||
async send(dto: SendBookDto, user: RequestUser): Promise<{ queued: number }> {
|
||||
async send(dto: SendBookDto, user: RequestUser): Promise<EmailSendAccepted> {
|
||||
const startedAt = Date.now();
|
||||
const requestedRecipientCount = (dto.recipientIds?.length ?? 0) + (dto.groupIds?.length ?? 0);
|
||||
this.logger.log(
|
||||
@@ -108,7 +108,7 @@ export class EmailSendOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
async resend(logEntryId: number, user: RequestUser): Promise<{ queued: number }> {
|
||||
async resend(logEntryId: number, user: RequestUser): Promise<EmailSendAccepted> {
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${EMAIL_RESEND_EVENT}] [start] userId=${user.id} logEntryId=${logEntryId} - resend enqueue started`);
|
||||
|
||||
@@ -173,7 +173,7 @@ export class EmailSendOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
async quickSend(bookId: number, user: RequestUser): Promise<{ queued: number }> {
|
||||
async quickSend(bookId: number, user: RequestUser): Promise<EmailSendAccepted> {
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${EMAIL_QUICK_SEND_EVENT}] [start] userId=${user.id} bookId=${bookId} - quick send enqueue started`);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { EmailTemplatePreview, EmailTemplate as EmailTemplateJson, EmailTemplateTextPreview } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import type { Unserialized } from '../../common/types/unserialized';
|
||||
import { EmailTemplateRepository } from './email-template.repository';
|
||||
import { EmailPreferencesRepository } from './email-preferences.repository';
|
||||
import { EmailTemplateContextService } from './email-template-context.service';
|
||||
@@ -14,6 +16,8 @@ import { UpdateEmailTemplateDto } from './dto/update-email-template.dto';
|
||||
import type { EmailTemplate } from '../../db/schema';
|
||||
import { isUniqueViolation } from './email-db-error.util';
|
||||
|
||||
type EmailTemplateResponse = Unserialized<EmailTemplateJson, 'createdAt' | 'updatedAt'>;
|
||||
|
||||
@Injectable()
|
||||
export class EmailTemplateService {
|
||||
constructor(
|
||||
@@ -24,18 +28,18 @@ export class EmailTemplateService {
|
||||
private readonly fileSelector: EmailFileSelector,
|
||||
) {}
|
||||
|
||||
async findAll(user: RequestUser) {
|
||||
async findAll(user: RequestUser): Promise<EmailTemplateResponse[]> {
|
||||
const templates = await this.repo.findAllForUser(user.id);
|
||||
const hasUserDefault = templates.some((t) => t.userId !== null && t.isDefault);
|
||||
if (!hasUserDefault) return templates;
|
||||
return templates.map((t) => (t.userId === null ? { ...t, isDefault: false } : t));
|
||||
}
|
||||
|
||||
async findOne(id: number, user: RequestUser) {
|
||||
async findOne(id: number, user: RequestUser): Promise<EmailTemplateResponse> {
|
||||
return this.getAccessible(id, user);
|
||||
}
|
||||
|
||||
async create(dto: CreateEmailTemplateDto, user: RequestUser) {
|
||||
async create(dto: CreateEmailTemplateDto, user: RequestUser): Promise<EmailTemplateResponse> {
|
||||
try {
|
||||
const [created] = await this.repo.insert({
|
||||
userId: user.id,
|
||||
@@ -52,7 +56,7 @@ export class EmailTemplateService {
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateEmailTemplateDto, user: RequestUser) {
|
||||
async update(id: number, dto: UpdateEmailTemplateDto, user: RequestUser): Promise<EmailTemplateResponse> {
|
||||
const template = await this.getAccessible(id, user);
|
||||
const isSuperuser = user.isSuperuser;
|
||||
|
||||
@@ -80,7 +84,7 @@ export class EmailTemplateService {
|
||||
await this.repo.delete(id, user.id);
|
||||
}
|
||||
|
||||
async setDefault(id: number, user: RequestUser) {
|
||||
async setDefault(id: number, user: RequestUser): Promise<EmailTemplateResponse> {
|
||||
const template = await this.getAccessible(id, user);
|
||||
if (template.userId !== user.id && !template.isSystem) {
|
||||
throw new ForbiddenException('Cannot set this template as default');
|
||||
@@ -92,7 +96,7 @@ export class EmailTemplateService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async preview(id: number, bookId: number, fileId: number | null, user: RequestUser) {
|
||||
async preview(id: number, bookId: number, fileId: number | null, user: RequestUser): Promise<EmailTemplatePreview> {
|
||||
const template = await this.getAccessible(id, user);
|
||||
// This route predates the attachment, so its response keeps exactly the shape released clients read.
|
||||
const { subject, bodyText } = await this.previewText(template.subject, template.bodyText, bookId, user, fileId);
|
||||
@@ -103,7 +107,13 @@ export class EmailTemplateService {
|
||||
* The text as a send of this book would read, and the file it would attach, named as the email names
|
||||
* it. Without a file, {{format}} and {{fileSize}} describe the edition a send would pick by default.
|
||||
*/
|
||||
async previewText(subject: string, bodyText: string, bookId: number, user: RequestUser, fileId: number | null = null) {
|
||||
async previewText(
|
||||
subject: string,
|
||||
bodyText: string,
|
||||
bookId: number,
|
||||
user: RequestUser,
|
||||
fileId: number | null = null,
|
||||
): Promise<Required<EmailTemplateTextPreview>> {
|
||||
await this.bookAccessService.assertUserCanAccessBook(bookId, user);
|
||||
const file = await this.fileSelector.select(bookId, fileId, null).catch(() => null);
|
||||
const context = await this.contextService.buildForBook(bookId, file?.id ?? null, user.name);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import fastifyCompress from '@fastify/compress';
|
||||
import { CanActivate, ExecutionContext, Injectable, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { EpubMediaOverlayPlaylist } from '@bookorbit/types';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { registerDeclaredCompressionOptOuts } from '../../../common/utils/bootstrap.utils';
|
||||
import { BookReadService } from '../../book/book-read.service';
|
||||
import { LibraryService } from '../../library/library.service';
|
||||
import { EpubController } from './epub.controller';
|
||||
import { EpubService } from './epub.service';
|
||||
|
||||
const reader = { id: 7, username: 'reader', isSuperuser: false, permissions: [] } as unknown as RequestUser;
|
||||
|
||||
@Injectable()
|
||||
class StubAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
context.switchToHttp().getRequest<{ user: RequestUser }>().user = reader;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const bookReadService = {
|
||||
findLibraryIdByBookId: vi.fn(),
|
||||
findFileById: vi.fn(),
|
||||
findPrimaryFilesByBookIds: vi.fn(),
|
||||
};
|
||||
|
||||
@Module({
|
||||
controllers: [EpubController],
|
||||
providers: [
|
||||
EpubService,
|
||||
{ provide: BookReadService, useValue: bookReadService },
|
||||
{ provide: LibraryService, useValue: { verifyUserAccess: vi.fn().mockResolvedValue(undefined) } },
|
||||
{ provide: APP_GUARD, useClass: StubAuthGuard },
|
||||
],
|
||||
})
|
||||
class EpubAudioTestModule {}
|
||||
|
||||
function audioBytes(seed: number, length = 20_000): Buffer {
|
||||
return Buffer.from(Array.from({ length }, (_, index) => (index * 31 + seed * 7) % 251));
|
||||
}
|
||||
|
||||
/** Each audio file, as the zip names it, how the OPF and SMIL refer to it, and how it is stored. */
|
||||
const AUDIO = [
|
||||
{ entry: 'OEBPS/Audio/chapter 1.mp4', href: 'Audio/chapter%201.mp4', inManifest: true, store: true, bytes: audioBytes(1) },
|
||||
{ entry: 'OEBPS/Audio/50% off.mp4', href: 'Audio/50%25%20off.mp4', inManifest: true, store: false, bytes: audioBytes(2) },
|
||||
{ entry: 'OEBPS/Audio/日本 語.mp4', href: 'Audio/%E6%97%A5%E6%9C%AC%20%E8%AA%9E.mp4', inManifest: true, store: true, bytes: audioBytes(3) },
|
||||
{ entry: 'OEBPS/Audio/unlisted.bin', href: 'Audio/unlisted.bin', inManifest: false, store: true, bytes: audioBytes(4) },
|
||||
] as const;
|
||||
|
||||
async function writeNarratedEpub(path: string): Promise<void> {
|
||||
const manifestAudio = AUDIO.filter((audio) => audio.inManifest)
|
||||
.map((audio, index) => `<item id="audio-${index}" href="${audio.href}" media-type="audio/mp4"/>`)
|
||||
.join('\n ');
|
||||
const pars = AUDIO.map(
|
||||
(audio, index) => `<par id="p${index}"><text src="../chapter.xhtml#s${index}"/><audio src="../${audio.href}" clipBegin="0s" clipEnd="1s"/></par>`,
|
||||
).join('\n ');
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
const complete = new Promise<void>((resolve, reject) => {
|
||||
archive.on('end', resolve);
|
||||
archive.on('error', reject);
|
||||
});
|
||||
archive.append('application/epub+zip', { name: 'mimetype', store: true });
|
||||
archive.append(
|
||||
'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
|
||||
{ name: 'META-INF/container.xml' },
|
||||
);
|
||||
archive.append(
|
||||
`<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="uid">urn:uuid:narrated</dc:identifier><dc:title>Narrated</dc:title></metadata>
|
||||
<manifest>
|
||||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" media-overlay="overlay"/>
|
||||
<item id="overlay" href="MediaOverlays/chapter.smil" media-type="application/smil+xml"/>
|
||||
${manifestAudio}
|
||||
</manifest>
|
||||
<spine><itemref idref="chapter"/></spine>
|
||||
</package>`,
|
||||
{ name: 'OEBPS/content.opf' },
|
||||
);
|
||||
archive.append('<html xmlns="http://www.w3.org/1999/xhtml"><body><p>Chapter</p></body></html>', { name: 'OEBPS/chapter.xhtml' });
|
||||
archive.append(
|
||||
`<smil xmlns="http://www.w3.org/ns/SMIL" version="3.0"><body><seq>
|
||||
${pars}
|
||||
</seq></body></smil>`,
|
||||
{ name: 'OEBPS/MediaOverlays/chapter.smil' },
|
||||
);
|
||||
for (const audio of AUDIO) archive.append(audio.bytes, { name: audio.entry, store: audio.store });
|
||||
await archive.finalize();
|
||||
await complete;
|
||||
await writeFile(path, Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
/** Percent-encodes each segment of a playlist href once, as the iOS player and reader both do. */
|
||||
function requestPath(href: string): string {
|
||||
return `/epub/1/media-overlay/file/${href.split('/').map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
describe('media-overlay audio over HTTP', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let root: string;
|
||||
let playlist: EpubMediaOverlayPlaylist;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'bookorbit-overlay-http-'));
|
||||
const epubPath = join(root, 'narrated.epub');
|
||||
await writeNarratedEpub(epubPath);
|
||||
bookReadService.findLibraryIdByBookId.mockResolvedValue(3);
|
||||
bookReadService.findPrimaryFilesByBookIds.mockResolvedValue([{ format: 'epub', absolutePath: epubPath, sizeBytes: null }]);
|
||||
|
||||
const moduleFixture = await Test.createTestingModule({ imports: [EpubAudioTestModule] }).compile();
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
// As in production: the opt-out hook first, then compression for everything else.
|
||||
registerDeclaredCompressionOptOuts(app.getHttpAdapter().getInstance());
|
||||
await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] });
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/epub/1/media-overlay' });
|
||||
playlist = response.json();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists every audio file by its decoded name', () => {
|
||||
expect(playlist.resources.map((resource) => resource.href)).toEqual(AUDIO.map((audio) => audio.entry));
|
||||
expect(playlist.resources.map((resource) => resource.size)).toEqual(AUDIO.map((audio) => audio.bytes.length));
|
||||
});
|
||||
|
||||
it.each(AUDIO.map((audio) => [audio.entry, audio] as const))('serves %s whole from a once-encoded request', async (_name, audio) => {
|
||||
const response = await app.inject({ method: 'GET', url: requestPath(audio.entry), headers: { 'accept-encoding': 'gzip, br' } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-encoding']).toBeUndefined();
|
||||
expect(response.headers['content-length']).toBe(String(audio.bytes.length));
|
||||
expect(response.headers['x-content-length-full']).toBe(String(audio.bytes.length));
|
||||
expect(response.headers['accept-ranges']).toBe('bytes');
|
||||
expect(response.headers['cache-control']).toBe('private, max-age=3600');
|
||||
expect(response.headers['content-type']).toBe(audio.inManifest ? 'audio/mp4' : 'application/octet-stream');
|
||||
expect(response.rawPayload.equals(audio.bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a stored entry', AUDIO[0]],
|
||||
['a deflated entry', AUDIO[1]],
|
||||
])('serves a byte range of %s', async (_case, audio) => {
|
||||
const response = await app.inject({ method: 'GET', url: requestPath(audio.entry), headers: { range: 'bytes=12000-15999' } });
|
||||
|
||||
expect(response.statusCode).toBe(206);
|
||||
expect(response.headers['content-range']).toBe(`bytes 12000-15999/${audio.bytes.length}`);
|
||||
expect(response.headers['content-length']).toBe('4000');
|
||||
expect(response.headers['x-content-length-full']).toBeUndefined();
|
||||
expect(response.rawPayload.equals(audio.bytes.subarray(12000, 16000))).toBe(true);
|
||||
});
|
||||
|
||||
it('answers a range past the end with 416', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: requestPath(AUDIO[0].entry), headers: { range: 'bytes=20000-' } });
|
||||
|
||||
expect(response.statusCode).toBe(416);
|
||||
expect(response.headers['content-range']).toBe('bytes */20000');
|
||||
expect(response.headers['content-length']).toBe('0');
|
||||
expect(response.rawPayload.length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not decode the path a second time', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/epub/1/media-overlay/file/OEBPS/Audio/chapter%25201.mp4' });
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('answers an open range past the end with 416 too', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: requestPath(AUDIO[1].entry), headers: { range: 'bytes=25000-' } });
|
||||
|
||||
expect(response.statusCode).toBe(416);
|
||||
expect(response.headers['content-range']).toBe('bytes */20000');
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,12 @@ describe('EPUB media overlay duration', () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeReadAlongEpub(chapters: [Clip[], Clip[]], durationMetas: string, packageXml = opf(durationMetas)): Promise<string> {
|
||||
async function writeReadAlongEpub(
|
||||
chapters: [Clip[], Clip[]],
|
||||
durationMetas: string,
|
||||
packageXml = opf(durationMetas),
|
||||
smilOverrides: Partial<Record<number, string>> = {},
|
||||
): Promise<string> {
|
||||
const path = join(fixtureRoot, `${Math.random().toString(36).slice(2)}.epub`);
|
||||
const archive = new ZipArchive({ zlib: { level: 0 } });
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -122,7 +127,7 @@ describe('EPUB media overlay duration', () => {
|
||||
archive.append(`<html xmlns="http://www.w3.org/1999/xhtml"><body><p>Chapter ${chapter}</p></body></html>`, {
|
||||
name: `OEBPS/chapter-${chapter}.xhtml`,
|
||||
});
|
||||
archive.append(smil(chapter, clips), { name: `OEBPS/MediaOverlays/chapter-${chapter}.smil` });
|
||||
archive.append(smilOverrides[chapter] ?? smil(chapter, clips), { name: `OEBPS/MediaOverlays/chapter-${chapter}.smil` });
|
||||
archive.append(Buffer.alloc(16, chapter), { name: `OEBPS/Audio/0000${chapter}.mp4` });
|
||||
}
|
||||
await archive.finalize();
|
||||
@@ -180,7 +185,9 @@ describe('EPUB media overlay duration', () => {
|
||||
expect.closeTo(14.51, 6),
|
||||
]);
|
||||
expect(playlist.items[1]).toEqual(expect.objectContaining({ clipBeginSeconds: 7223.15, clipEndSeconds: 7198.408 }));
|
||||
expect(playlist.sections.map((section) => section.durationSeconds)).toEqual([null, expect.closeTo(19.15, 6)]);
|
||||
// The first chapter's clip cannot be measured, so its declared overlay duration stands in.
|
||||
expect(playlist.sections.map((section) => section.durationSeconds)).toEqual([expect.closeTo(8, 6), expect.closeTo(19.15, 6)]);
|
||||
expect(playlist.sections.map((section) => section.startSeconds)).toEqual([0, expect.closeTo(8, 6)]);
|
||||
});
|
||||
|
||||
it('reports the declared total as the stored read-aloud capability', async () => {
|
||||
@@ -291,4 +298,81 @@ describe('EPUB media overlay duration', () => {
|
||||
expect(playlist.items).toHaveLength(2);
|
||||
expect(playlist.durationSeconds).toBeNull();
|
||||
});
|
||||
|
||||
describe('section clock', () => {
|
||||
const UNMEASURED_LAST_CLIP = withLastClipOfChapterOne({ begin: '3.620s' });
|
||||
const CHAPTER_TWO_SECONDS = 4.64 + 14.51;
|
||||
|
||||
it('takes an unmeasured section length from its overlay media:duration and starts the next section after it', async () => {
|
||||
const path = await writeReadAlongEpub(
|
||||
UNMEASURED_LAST_CLIP,
|
||||
`<meta property="media:duration" refines="#chapter-1_overlay">00:00:12.50</meta>\n ${STORYTELLER_PACKAGE_DURATION}`,
|
||||
);
|
||||
|
||||
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
|
||||
|
||||
expect(playlist.sections.map((section) => section.durationSeconds)).toEqual([12.5, expect.closeTo(CHAPTER_TWO_SECONDS, 6)]);
|
||||
expect(playlist.sections.map((section) => section.startSeconds)).toEqual([0, 12.5]);
|
||||
expect(playlist.items[1]?.durationSeconds).toBeNull();
|
||||
expect(playlist.durationSeconds).toBeCloseTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6);
|
||||
});
|
||||
|
||||
it('keeps counting only measured clips when the overlay declares no length', async () => {
|
||||
const path = await writeReadAlongEpub(UNMEASURED_LAST_CLIP, STORYTELLER_PACKAGE_DURATION);
|
||||
|
||||
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
|
||||
|
||||
expect(playlist.sections.map((section) => section.durationSeconds)).toEqual([null, expect.closeTo(CHAPTER_TWO_SECONDS, 6)]);
|
||||
expect(playlist.sections.map((section) => section.startSeconds)).toEqual([0, expect.closeTo(3.62, 6)]);
|
||||
});
|
||||
|
||||
it('ignores a declared overlay length shorter than the clips already measured', async () => {
|
||||
const path = await writeReadAlongEpub(UNMEASURED_LAST_CLIP, '<meta property="media:duration" refines="#chapter-1_overlay">00:00:02</meta>');
|
||||
|
||||
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
|
||||
|
||||
expect(playlist.sections[0]?.durationSeconds).toBeNull();
|
||||
expect(playlist.sections[1]?.startSeconds).toBeCloseTo(3.62, 6);
|
||||
});
|
||||
|
||||
it('prefers measured clips to a declared overlay length', async () => {
|
||||
const path = await writeReadAlongEpub(MEASURABLE_CHAPTERS, '<meta property="media:duration" refines="#chapter-1_overlay">00:01:00</meta>');
|
||||
|
||||
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
|
||||
|
||||
expect(playlist.sections.map((section) => section.durationSeconds)).toEqual([expect.closeTo(11.96, 6), expect.closeTo(CHAPTER_TWO_SECONDS, 6)]);
|
||||
expect(playlist.sections[1]?.startSeconds).toBeCloseTo(11.96, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the reading order of a seq that interleaves par and seq children', async () => {
|
||||
const par = (fragment: string, begin: number, audioFirst = false) => {
|
||||
const text = `<text src="../chapter-1.xhtml#${fragment}"/>`;
|
||||
const audio = `<audio src="../Audio/00001.mp4" clipBegin="${begin}s" clipEnd="${begin + 1}s"/>`;
|
||||
return `<par id="${fragment}">${audioFirst ? audio + text : text + audio}</par>`;
|
||||
};
|
||||
const interleaved = `<smil xmlns="http://www.w3.org/ns/SMIL" version="3.0">
|
||||
<body>
|
||||
<seq id="chapter-1_overlay" epub:textref="../chapter-1.xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
${par('s1', 0)}
|
||||
<seq id="aside">
|
||||
${par('s2', 1)}
|
||||
<seq id="note">${par('s3', 2)}</seq>
|
||||
</seq>
|
||||
${par('s4', 3, true)}
|
||||
<seq id="coda">${par('s5', 4)}</seq>
|
||||
${par('s6', 5)}
|
||||
</seq>
|
||||
</body>
|
||||
</smil>`;
|
||||
const path = await writeReadAlongEpub(MEASURABLE_CHAPTERS, '', undefined, { 1: interleaved });
|
||||
|
||||
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
|
||||
const chapterOne = playlist.items.filter((item) => item.sectionIndex === 0);
|
||||
|
||||
expect(chapterOne.map((item) => item.textFragment)).toEqual(['s1', 's2', 's3', 's4', 's5', 's6']);
|
||||
expect(chapterOne.map((item) => item.clipBeginSeconds)).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(playlist.items.map((item) => item.index)).toEqual(playlist.items.map((_, index) => index));
|
||||
expect(playlist.sections[0]?.durationSeconds).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,25 @@ import type {
|
||||
EpubTocItem,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
const smilParser = new XMLParser({
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
textNodeName: '#text',
|
||||
});
|
||||
|
||||
// Grouping same-named children would move a <seq> nested between two <par>s after both.
|
||||
const smilParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
textNodeName: '#text',
|
||||
preserveOrder: true,
|
||||
});
|
||||
|
||||
interface OrderedXmlNode {
|
||||
[name: string]: unknown;
|
||||
':@'?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toArray<T>(v: T | T[] | undefined | null): T[] {
|
||||
if (v == null) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
@@ -30,11 +43,9 @@ function safeDecodeURI(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEpubZipPath(path: string): string {
|
||||
const clean = safeDecodeURI((path ?? '').replace(/\\/g, '/')).replace(/^\/+/, '');
|
||||
const parts = clean.split('/');
|
||||
function joinZipPathSegments(path: string): string {
|
||||
const resolved: string[] = [];
|
||||
for (const part of parts) {
|
||||
for (const part of path.replace(/^\/+/, '').split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (resolved.length > 0) resolved.pop();
|
||||
@@ -45,6 +56,15 @@ export function normalizeEpubZipPath(path: string): string {
|
||||
return resolved.join('/');
|
||||
}
|
||||
|
||||
export function normalizeEpubZipPath(path: string): string {
|
||||
return joinZipPathSegments(safeDecodeURI((path ?? '').replace(/\\/g, '/')));
|
||||
}
|
||||
|
||||
/** The same normalization without percent-decoding, for a path that has already been decoded once. */
|
||||
export function cleanEpubZipPath(path: string): string {
|
||||
return joinZipPathSegments((path ?? '').replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
export function resolveEpubHref(href: string, basePath: string): string {
|
||||
if (!href || /^[a-z][a-z\d+.-]*:/i.test(href)) return href;
|
||||
const decoded = safeDecodeURI(href);
|
||||
@@ -58,16 +78,24 @@ function zipDir(path: string): string {
|
||||
return path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry named exactly `path` wins. Decoded names are compared only after that: hrefs arrive
|
||||
* decoded, so an entry whose name holds a literal escape such as `%20` is found only that way, but
|
||||
* it must not answer for the entry its escape decodes to.
|
||||
*/
|
||||
export function findEpubZipEntry(files: unzipper.File[], path: string): unzipper.File | undefined {
|
||||
const exact = cleanEpubZipPath(path);
|
||||
const clean = normalizeEpubZipPath(path);
|
||||
const cleanLower = clean.toLowerCase();
|
||||
let decodedMatch: unzipper.File | undefined;
|
||||
let ciMatch: unzipper.File | undefined;
|
||||
for (const file of files) {
|
||||
if (cleanEpubZipPath(file.path) === exact) return file;
|
||||
const fp = normalizeEpubZipPath(file.path);
|
||||
if (fp === clean) return file;
|
||||
if (!decodedMatch && fp === clean) decodedMatch = file;
|
||||
if (!ciMatch && fp.toLowerCase() === cleanLower) ciMatch = file;
|
||||
}
|
||||
return ciMatch;
|
||||
return decodedMatch ?? ciMatch;
|
||||
}
|
||||
|
||||
function parseClock(value: unknown): number | null {
|
||||
@@ -93,15 +121,28 @@ function parseClock(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectPars(node: unknown, acc: Record<string, unknown>[] = []): Record<string, unknown>[] {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) collectPars(child, acc);
|
||||
return acc;
|
||||
function elementName(node: OrderedXmlNode): string | null {
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key !== ':@' && key !== '#text') return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function elementChildren(node: OrderedXmlNode): OrderedXmlNode[] {
|
||||
const name = elementName(node);
|
||||
const children = name ? node[name] : null;
|
||||
return Array.isArray(children) ? (children as OrderedXmlNode[]) : [];
|
||||
}
|
||||
|
||||
/** Every element holding both a <text> and an <audio> child, in document order. */
|
||||
function collectPars(nodes: OrderedXmlNode[], acc: Array<{ text: OrderedXmlNode; audio: OrderedXmlNode }> = []) {
|
||||
for (const node of nodes) {
|
||||
const children = elementChildren(node);
|
||||
const text = children.find((child) => elementName(child) === 'text');
|
||||
const audio = children.find((child) => elementName(child) === 'audio');
|
||||
if (text && audio) acc.push({ text, audio });
|
||||
collectPars(children, acc);
|
||||
}
|
||||
if (typeof node !== 'object' || node === null) return acc;
|
||||
const obj = node as Record<string, unknown>;
|
||||
if (obj.text != null && obj.audio != null) acc.push(obj);
|
||||
for (const value of Object.values(obj)) collectPars(value, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -111,28 +152,45 @@ function attr(node: unknown, name: string): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function orderedAttr(node: OrderedXmlNode, name: string): string | null {
|
||||
return attr(node[':@'], name);
|
||||
}
|
||||
|
||||
interface PackageMediaDurations {
|
||||
totalSeconds: number | null;
|
||||
byOverlayId: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total narration length every EPUB 3 Media Overlay package must declare, in a
|
||||
* `media:duration` meta that refines nothing. It stands in when a clip cannot be measured:
|
||||
* Storyteller 2.x ends the last clip of each audio track at the track's real length, which can
|
||||
* fall before the clip's start, and one such clip voids the whole clip sum.
|
||||
* The `media:duration` metas every EPUB 3 Media Overlay package must declare: the total, which
|
||||
* refines nothing, and one per overlay document, refining its manifest item. They stand in when a
|
||||
* clip cannot be measured: Storyteller 2.x ends the last clip of each audio track at the track's
|
||||
* real length, which can fall before the clip's start, and one such clip voids the clip sum.
|
||||
*/
|
||||
async function readPackageMediaDurationSeconds(zip: unzipper.CentralDirectory, opfPath: string): Promise<number | null> {
|
||||
async function readPackageMediaDurations(zip: unzipper.CentralDirectory, opfPath: string): Promise<PackageMediaDurations> {
|
||||
const durations: PackageMediaDurations = { totalSeconds: null, byOverlayId: new Map() };
|
||||
const entry = opfPath ? findEpubZipEntry(zip.files, opfPath) : undefined;
|
||||
if (!entry) return null;
|
||||
if (!entry) return durations;
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
pkg = parseEpubPackage(await entry.buffer());
|
||||
} catch {
|
||||
return null;
|
||||
return durations;
|
||||
}
|
||||
const metadata = pkg['metadata'] as Record<string, unknown> | undefined;
|
||||
for (const meta of toArray(metadata?.meta)) {
|
||||
if (attr(meta, 'property') !== 'media:duration' || attr(meta, 'refines') != null) continue;
|
||||
if (attr(meta, 'property') !== 'media:duration') continue;
|
||||
const seconds = parseClock((meta as Record<string, unknown>)['#text']);
|
||||
if (seconds != null && Number.isFinite(seconds) && seconds > 0) return seconds;
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) continue;
|
||||
const refines = attr(meta, 'refines');
|
||||
if (refines == null) {
|
||||
durations.totalSeconds ??= seconds;
|
||||
} else {
|
||||
const overlayId = refines.replace(/^#/, '');
|
||||
if (overlayId && !durations.byOverlayId.has(overlayId)) durations.byOverlayId.set(overlayId, seconds);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return durations;
|
||||
}
|
||||
|
||||
function splitHrefFragment(href: string): { href: string; fragment: string | null } {
|
||||
@@ -145,7 +203,7 @@ async function parseEpubMediaOverlayBookInfo(epubPath: string): Promise<EpubBook
|
||||
const containerEntry = findEpubZipEntry(zip.files, 'META-INF/container.xml');
|
||||
if (!containerEntry) throw new Error('Missing META-INF/container.xml');
|
||||
|
||||
const containerDoc = smilParser.parse(await containerEntry.buffer()) as Record<string, unknown>;
|
||||
const containerDoc = xmlParser.parse(await containerEntry.buffer()) as Record<string, unknown>;
|
||||
const container = containerDoc['container'] as Record<string, unknown>;
|
||||
const rootfiles = (container?.rootfiles as Record<string, unknown>)?.rootfile;
|
||||
const rootfile: unknown = Array.isArray(rootfiles) ? rootfiles[0] : rootfiles;
|
||||
@@ -236,16 +294,16 @@ async function parseSmilItems(
|
||||
const entry = findEpubZipEntry(zip.files, smilHref);
|
||||
if (!entry) return [];
|
||||
const smilDir = zipDir(smilHref);
|
||||
const doc = smilParser.parse(await entry.buffer()) as Record<string, unknown>;
|
||||
const doc = smilParser.parse(await entry.buffer()) as OrderedXmlNode[];
|
||||
return collectPars(doc)
|
||||
.map((par) => {
|
||||
const textSrc = attr(par.text, 'src');
|
||||
const audioSrc = attr(par.audio, 'src');
|
||||
const textSrc = orderedAttr(par.text, 'src');
|
||||
const audioSrc = orderedAttr(par.audio, 'src');
|
||||
if (!textSrc || !audioSrc) return null;
|
||||
const text = splitHrefFragment(resolveEpubHref(textSrc, smilDir));
|
||||
const audio = splitHrefFragment(resolveEpubHref(audioSrc, smilDir));
|
||||
const begin = parseClock(attr(par.audio, 'clipBegin')) ?? 0;
|
||||
const end = parseClock(attr(par.audio, 'clipEnd'));
|
||||
const begin = parseClock(orderedAttr(par.audio, 'clipBegin')) ?? 0;
|
||||
const end = parseClock(orderedAttr(par.audio, 'clipEnd'));
|
||||
const duration = end != null && end >= begin ? end - begin : null;
|
||||
return {
|
||||
textHref: text.href,
|
||||
@@ -275,6 +333,9 @@ export async function buildEpubMediaOverlayPlaylist(
|
||||
const audioHrefs = new Set<string>();
|
||||
let totalKnownDuration = 0;
|
||||
let hasUnknownDuration = false;
|
||||
let clockSeconds = 0;
|
||||
let packageDurations: Promise<PackageMediaDurations> | undefined;
|
||||
const loadPackageDurations = () => (packageDurations ??= readPackageMediaDurations(zip, info.containerPath));
|
||||
|
||||
for (let sectionIndex = 0; sectionIndex < info.spine.length; sectionIndex += 1) {
|
||||
const spineItem = info.spine[sectionIndex];
|
||||
@@ -285,18 +346,19 @@ export async function buildEpubMediaOverlayPlaylist(
|
||||
const overlay = manifestById.get(overlayId);
|
||||
if (!overlay) continue;
|
||||
|
||||
const sectionStart = totalKnownDuration;
|
||||
let sectionDuration: number | null = 0;
|
||||
const sectionStart = clockSeconds;
|
||||
let knownSectionSeconds = 0;
|
||||
let sectionHasUnknownClip = false;
|
||||
const smilItems = await parseSmilItems(zip, overlay.href);
|
||||
for (const smilItem of smilItems) {
|
||||
const audioManifest = manifestByHref.get(smilItem.audioHref);
|
||||
audioHrefs.add(smilItem.audioHref);
|
||||
if (smilItem.durationSeconds == null) {
|
||||
hasUnknownDuration = true;
|
||||
sectionDuration = null;
|
||||
sectionHasUnknownClip = true;
|
||||
} else {
|
||||
totalKnownDuration += smilItem.durationSeconds;
|
||||
if (sectionDuration != null) sectionDuration += smilItem.durationSeconds;
|
||||
knownSectionSeconds += smilItem.durationSeconds;
|
||||
}
|
||||
items.push({
|
||||
index: items.length,
|
||||
@@ -313,6 +375,14 @@ export async function buildEpubMediaOverlayPlaylist(
|
||||
});
|
||||
}
|
||||
|
||||
let sectionDuration: number | null = knownSectionSeconds;
|
||||
if (sectionHasUnknownClip) {
|
||||
// A declared length shorter than the clips already measured is wrong; the section stays unknown.
|
||||
const declared = (await loadPackageDurations()).byOverlayId.get(overlayId);
|
||||
sectionDuration = declared != null && declared >= knownSectionSeconds ? declared : null;
|
||||
}
|
||||
clockSeconds += sectionDuration ?? knownSectionSeconds;
|
||||
|
||||
if (smilItems.length > 0) {
|
||||
sections.push({
|
||||
index: sectionIndex,
|
||||
@@ -337,7 +407,7 @@ export async function buildEpubMediaOverlayPlaylist(
|
||||
return {
|
||||
bookId,
|
||||
fileId,
|
||||
durationSeconds: hasUnknownDuration ? await readPackageMediaDurationSeconds(zip, info.containerPath) : totalKnownDuration,
|
||||
durationSeconds: hasUnknownDuration ? (await loadPackageDurations()).totalSeconds : totalKnownDuration,
|
||||
items,
|
||||
sections,
|
||||
resources,
|
||||
@@ -366,7 +436,7 @@ export async function inspectEpubMediaOverlayFile(epubPath: string): Promise<Epu
|
||||
const containerEntry = findEpubZipEntry(zip.files, 'META-INF/container.xml');
|
||||
if (!containerEntry) return { available: false, durationSeconds: null };
|
||||
|
||||
const containerDoc = smilParser.parse(await containerEntry.buffer()) as Record<string, unknown>;
|
||||
const containerDoc = xmlParser.parse(await containerEntry.buffer()) as Record<string, unknown>;
|
||||
const container = containerDoc['container'] as Record<string, unknown>;
|
||||
const rootfiles = (container?.rootfiles as Record<string, unknown>)?.rootfile;
|
||||
const rootfile: unknown = Array.isArray(rootfiles) ? rootfiles[0] : rootfiles;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import * as unzipper from 'unzipper';
|
||||
|
||||
import { openEpubZipEntryRange } from './epub-zip-entry-stream';
|
||||
|
||||
async function readAll(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
describe('openEpubZipEntryRange', () => {
|
||||
let root!: string;
|
||||
let archivePath!: string;
|
||||
let files!: unzipper.File[];
|
||||
// Large enough to inflate in many chunks, and compressible so the deflated entry is really deflated.
|
||||
const audio = Buffer.concat(Array.from({ length: 64 }, (_, index) => Buffer.concat([randomBytes(512), Buffer.alloc(4096, index)])));
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'bookorbit-zip-range-'));
|
||||
archivePath = join(root, 'book.epub');
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
const complete = new Promise<void>((resolve, reject) => {
|
||||
archive.on('end', resolve);
|
||||
archive.on('error', reject);
|
||||
});
|
||||
archive.append('application/epub+zip', { name: 'mimetype', store: true });
|
||||
archive.append(audio, { name: 'OEBPS/Audio/stored.mp3', store: true });
|
||||
archive.append(audio, { name: 'OEBPS/Audio/deflated.mp3' });
|
||||
await archive.finalize();
|
||||
await complete;
|
||||
await writeFile(archivePath, Buffer.concat(chunks));
|
||||
files = (await unzipper.Open.file(archivePath)).files;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const entry = (name: string) => files.find((file) => file.path === `OEBPS/Audio/${name}`)!;
|
||||
|
||||
it('lays out the fixture as one stored and one deflated entry', () => {
|
||||
expect(entry('stored.mp3').compressionMethod).toBe(0);
|
||||
expect(entry('deflated.mp3').compressionMethod).toBe(8);
|
||||
expect(entry('deflated.mp3').compressedSize).toBeLessThan(audio.length);
|
||||
});
|
||||
|
||||
it.each(['stored.mp3', 'deflated.mp3'])('reads the whole of %s', async (name) => {
|
||||
const body = await readAll(await openEpubZipEntryRange(archivePath, entry(name), 0, audio.length - 1));
|
||||
|
||||
expect(body.equals(audio)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the first bytes', 0, 99],
|
||||
['a span inside one chunk', 70_000, 70_010],
|
||||
['a span across many chunks', 1_000, 200_000],
|
||||
['the last byte', audio.length - 1, audio.length - 1],
|
||||
['the tail', audio.length - 5_000, audio.length - 1],
|
||||
])('reads %s of a stored and a deflated entry alike', async (_case, start, end) => {
|
||||
const stored = await readAll(await openEpubZipEntryRange(archivePath, entry('stored.mp3'), start, end));
|
||||
const deflated = await readAll(await openEpubZipEntryRange(archivePath, entry('deflated.mp3'), start, end));
|
||||
|
||||
const expected = audio.subarray(start, end + 1);
|
||||
expect(stored.equals(expected)).toBe(true);
|
||||
expect(deflated.equals(expected)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a range outside the entry', async () => {
|
||||
await expect(openEpubZipEntryRange(archivePath, entry('stored.mp3'), 0, audio.length)).rejects.toThrow(RangeError);
|
||||
await expect(openEpubZipEntryRange(archivePath, entry('stored.mp3'), 10, 9)).rejects.toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('refuses an encrypted entry instead of serving its ciphertext', async () => {
|
||||
const encrypted = { ...entry('stored.mp3'), flags: entry('stored.mp3').flags | 0x01 } as unzipper.File;
|
||||
|
||||
await expect(openEpubZipEntryRange(archivePath, encrypted, 0, 9)).rejects.toThrow('Encrypted zip entry');
|
||||
});
|
||||
|
||||
it('stops inflating once a stream is destroyed', async () => {
|
||||
const stream = await openEpubZipEntryRange(archivePath, entry('deflated.mp3'), 0, audio.length - 1);
|
||||
const closed = new Promise<void>((resolve) => stream.once('close', () => resolve()));
|
||||
|
||||
for await (const chunk of stream) {
|
||||
expect((chunk as Buffer).length).toBeGreaterThan(0);
|
||||
break;
|
||||
}
|
||||
|
||||
await closed;
|
||||
expect(stream.destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createReadStream } from 'fs';
|
||||
import { pipeline, Readable } from 'stream';
|
||||
import type * as unzipper from 'unzipper';
|
||||
import { createInflateRaw } from 'zlib';
|
||||
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const LOCAL_FILE_HEADER_LENGTH = 30;
|
||||
const COMPRESSION_STORED = 0;
|
||||
const COMPRESSION_DEFLATED = 8;
|
||||
const FLAG_ENCRYPTED = 0x01;
|
||||
|
||||
async function readBytes(path: string, start: number, length: number): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of createReadStream(path, { start, end: start + length - 1 })) chunks.push(chunk as Buffer);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** The entry's data follows its local header, whose name and extra field can differ in length from the central directory's. */
|
||||
async function entryDataOffset(archivePath: string, entry: unzipper.File): Promise<number> {
|
||||
const header = await readBytes(archivePath, entry.offsetToLocalFileHeader, LOCAL_FILE_HEADER_LENGTH);
|
||||
if (header.length < LOCAL_FILE_HEADER_LENGTH || header.readUInt32LE(0) !== LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
throw new Error(`Invalid local file header for zip entry: ${entry.path}`);
|
||||
}
|
||||
const nameLength = header.readUInt16LE(26);
|
||||
const extraLength = header.readUInt16LE(28);
|
||||
return entry.offsetToLocalFileHeader + LOCAL_FILE_HEADER_LENGTH + nameLength + extraLength;
|
||||
}
|
||||
|
||||
async function* inflatedSlice(archivePath: string, dataStart: number, compressedSize: number, skip: number, take: number): AsyncGenerator<Buffer> {
|
||||
// Leaving the loop early destroys the inflater, and pipeline then closes the file.
|
||||
const inflated = pipeline(createReadStream(archivePath, { start: dataStart, end: dataStart + compressedSize - 1 }), createInflateRaw(), () => {});
|
||||
let toSkip = skip;
|
||||
let remaining = take;
|
||||
for await (const chunk of inflated as AsyncIterable<Buffer>) {
|
||||
if (toSkip >= chunk.length) {
|
||||
toSkip -= chunk.length;
|
||||
continue;
|
||||
}
|
||||
const slice = chunk.subarray(toSkip, toSkip + remaining);
|
||||
toSkip = 0;
|
||||
remaining -= slice.length;
|
||||
yield slice;
|
||||
if (remaining === 0) return;
|
||||
}
|
||||
throw new Error('Zip entry ended before the requested range');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes `start` to `end` (inclusive) of a zip entry's uncompressed content, read without holding
|
||||
* the entry in memory. A stored entry, as narration audio usually is, is read straight from its
|
||||
* offset; a deflated one is inflated from the start and the bytes before `start` dropped.
|
||||
*/
|
||||
export async function openEpubZipEntryRange(archivePath: string, entry: unzipper.File, start: number, end: number): Promise<Readable> {
|
||||
if (start < 0 || end < start || end >= entry.uncompressedSize) {
|
||||
throw new RangeError(`Range ${start}-${end} is outside zip entry ${entry.path}`);
|
||||
}
|
||||
if (entry.flags & FLAG_ENCRYPTED) throw new Error(`Encrypted zip entry: ${entry.path}`);
|
||||
const dataStart = await entryDataOffset(archivePath, entry);
|
||||
|
||||
if (entry.compressionMethod === COMPRESSION_STORED) {
|
||||
if (entry.compressedSize !== entry.uncompressedSize) throw new Error(`Stored zip entry sizes disagree: ${entry.path}`);
|
||||
return createReadStream(archivePath, { start: dataStart + start, end: dataStart + end });
|
||||
}
|
||||
if (entry.compressionMethod === COMPRESSION_DEFLATED) {
|
||||
return Readable.from(inflatedSlice(archivePath, dataStart, entry.compressedSize, start, end - start + 1), { objectMode: false });
|
||||
}
|
||||
throw new Error(`Unsupported compression method ${entry.compressionMethod} for zip entry: ${entry.path}`);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FASTIFY_ROUTE_CONFIG_METADATA } from '@nestjs/platform-fastify/constants.js';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
import { EpubController } from './epub.controller';
|
||||
@@ -73,15 +74,17 @@ describe('EpubController', () => {
|
||||
expect(epubService.getMediaOverlayPlaylist).toHaveBeenCalledWith(11, 12, user);
|
||||
});
|
||||
|
||||
it('sets range headers for media-overlay audio files', async () => {
|
||||
it('sets range headers for media-overlay audio files and streams the body', async () => {
|
||||
const user = { id: 1, isSuperuser: false, permissions: [] } as any;
|
||||
const reply = {
|
||||
code: vi.fn().mockReturnThis(),
|
||||
header: vi.fn(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
const body = new PassThrough();
|
||||
epubService.streamMediaOverlayFile.mockResolvedValue({
|
||||
data: Buffer.from('2345'),
|
||||
body,
|
||||
length: 4,
|
||||
contentType: 'audio/mpeg',
|
||||
size: 10,
|
||||
status: 206,
|
||||
@@ -92,11 +95,78 @@ describe('EpubController', () => {
|
||||
|
||||
expect(epubService.streamMediaOverlayFile).toHaveBeenCalledWith(9, 'OPS/audio/ch1.mp3', 13, 'bytes=2-5', user);
|
||||
expect(reply.code).toHaveBeenCalledWith(206);
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'audio/mpeg');
|
||||
expect(reply.header).toHaveBeenCalledWith('Accept-Ranges', 'bytes');
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Length', 4);
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Range', 'bytes 2-5/10');
|
||||
expect(reply.send).toHaveBeenCalledWith(Buffer.from('2345'));
|
||||
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'private, max-age=3600');
|
||||
expect(reply.header).not.toHaveBeenCalledWith('X-Content-Length-Full', expect.anything());
|
||||
expect(reply.send).toHaveBeenCalledWith(body);
|
||||
});
|
||||
|
||||
// A streamed body the compressor took would lose the Content-Length the ranges are read against.
|
||||
it('opts the media-overlay audio route out of response compression', () => {
|
||||
expect(Reflect.getMetadata(FASTIFY_ROUTE_CONFIG_METADATA, EpubController.prototype.getMediaOverlayFile)).toEqual({ compress: false });
|
||||
});
|
||||
|
||||
it('answers an unsatisfiable media-overlay range with 416 and an empty body', async () => {
|
||||
const reply = { code: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() };
|
||||
epubService.streamMediaOverlayFile.mockResolvedValue({
|
||||
body: Buffer.alloc(0),
|
||||
length: 0,
|
||||
contentType: 'audio/mpeg',
|
||||
size: 10,
|
||||
status: 416,
|
||||
contentRange: 'bytes */10',
|
||||
});
|
||||
|
||||
await controller.getMediaOverlayFile(9, 'OPS/audio/ch1.mp3', '13', { id: 1 } as any, { headers: { range: 'bytes=20-' } } as any, reply as any);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(416);
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Length', 0);
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Range', 'bytes */10');
|
||||
expect(reply.send).toHaveBeenCalledWith(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('names the full length of a whole media-overlay file', async () => {
|
||||
const reply = { code: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() };
|
||||
epubService.streamMediaOverlayFile.mockResolvedValue({
|
||||
body: new PassThrough(),
|
||||
length: 10,
|
||||
contentType: 'audio/mpeg',
|
||||
size: 10,
|
||||
status: 200,
|
||||
contentRange: null,
|
||||
});
|
||||
|
||||
await controller.getMediaOverlayFile(9, 'OPS/audio/ch1.mp3', '13', { id: 1 } as any, { headers: {} } as any, reply as any);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(200);
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Length', 10);
|
||||
expect(reply.header).toHaveBeenCalledWith('X-Content-Length-Full', 10);
|
||||
expect(reply.header).not.toHaveBeenCalledWith('Content-Range', expect.anything());
|
||||
});
|
||||
|
||||
// The router has decoded the wildcard: `%25` arrived as a literal `%`, which a second pass rejected.
|
||||
it.each(['OPS/audio/50% off.mp3', 'OPS/audio/track%201.mp3', 'OPS/audio/日本語 1.mp3'])(
|
||||
'passes the media-overlay path %s on as the router decoded it',
|
||||
async (filePath) => {
|
||||
const reply = { code: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() };
|
||||
epubService.streamMediaOverlayFile.mockResolvedValue({
|
||||
body: Buffer.from('x'),
|
||||
length: 1,
|
||||
contentType: 'audio/mpeg',
|
||||
size: 1,
|
||||
status: 200,
|
||||
contentRange: null,
|
||||
});
|
||||
|
||||
await controller.getMediaOverlayFile(9, filePath, '13', { id: 1 } as any, { headers: {} } as any, reply as any);
|
||||
|
||||
expect(epubService.streamMediaOverlayFile).toHaveBeenCalledWith(9, filePath, 13, undefined, { id: 1 });
|
||||
},
|
||||
);
|
||||
|
||||
it('does not set content-length when size is zero', async () => {
|
||||
const user = { id: 1, isSuperuser: false, permissions: [] } as any;
|
||||
const stream = new PassThrough();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Controller, Get, Param, ParseIntPipe, Query, Req, Res } from '@nestjs/common';
|
||||
import { RouteConfig } from '@nestjs/platform-fastify';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
@@ -19,18 +20,19 @@ export class EpubController {
|
||||
return this.epubService.getMediaOverlayPlaylist(bookId, this.parseFileId(fileId), user);
|
||||
}
|
||||
|
||||
/** The router has already decoded the path once; decoding it again would read a literal `%` in a name as an escape. */
|
||||
@Get(':bookId/media-overlay/file/*')
|
||||
@RouteConfig({ compress: false })
|
||||
async getMediaOverlayFile(
|
||||
@Param('bookId', ParseIntPipe) bookId: number,
|
||||
@Param('*') encodedPath: string,
|
||||
@Param('*') filePath: string,
|
||||
@Query('fileId') fileId: string | undefined,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const filePath = this.decodePathParam(encodedPath);
|
||||
const range = typeof request.headers.range === 'string' ? request.headers.range : undefined;
|
||||
const { data, contentType, size, status, contentRange } = await this.epubService.streamMediaOverlayFile(
|
||||
const { body, length, contentType, size, status, contentRange } = await this.epubService.streamMediaOverlayFile(
|
||||
bookId,
|
||||
filePath,
|
||||
this.parseFileId(fileId),
|
||||
@@ -41,11 +43,11 @@ export class EpubController {
|
||||
reply.code(status);
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', data.length);
|
||||
reply.header('Content-Length', length);
|
||||
if (contentRange) reply.header('Content-Range', contentRange);
|
||||
if (!contentRange && size > 0) reply.header('X-Content-Length-Full', size);
|
||||
reply.header('Cache-Control', 'private, max-age=3600');
|
||||
reply.send(data);
|
||||
reply.send(body);
|
||||
}
|
||||
|
||||
@Get(':bookId/file/*')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
vi.mock('fs/promises', () => ({ stat: vi.fn() }));
|
||||
vi.mock('unzipper', () => ({ Open: { file: vi.fn() } }));
|
||||
vi.mock('./epub-zip-entry-stream', () => ({ openEpubZipEntryRange: vi.fn() }));
|
||||
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
@@ -8,11 +9,13 @@ import { Readable } from 'stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
|
||||
import { EpubService } from './epub.service';
|
||||
import { openEpubZipEntryRange } from './epub-zip-entry-stream';
|
||||
import { BookReadService } from '../../book/book-read.service';
|
||||
import { LibraryService } from '../../library/library.service';
|
||||
|
||||
const mockStat = stat as MockedFunction<typeof stat>;
|
||||
const mockOpenFile = (unzipper as any).Open.file as vi.Mock;
|
||||
const mockOpenRange = openEpubZipEntryRange as MockedFunction<typeof openEpubZipEntryRange>;
|
||||
|
||||
interface ZipEntrySpec {
|
||||
path: string;
|
||||
@@ -161,17 +164,29 @@ const SMIL_XML = `
|
||||
</smil>
|
||||
`;
|
||||
|
||||
function makeMediaOverlayArchive(smil = SMIL_XML) {
|
||||
function makeMediaOverlayArchive(
|
||||
smil = SMIL_XML,
|
||||
audioEntries: ZipEntrySpec[] = [{ path: 'OPS/audio/ch1.mp3', content: Buffer.from('0123456789') }],
|
||||
) {
|
||||
return makeArchive([
|
||||
{ path: 'META-INF/container.xml', content: CONTAINER_XML },
|
||||
{ path: 'OPS/content.opf', content: OPF_MEDIA_OVERLAY_XML },
|
||||
{ path: 'OPS/nav.xhtml', content: NAV_XHTML },
|
||||
{ path: 'OPS/text/ch1.xhtml', content: '<h1>ch1</h1>' },
|
||||
{ path: 'OPS/smil/ch1.smil', content: smil },
|
||||
{ path: 'OPS/audio/ch1.mp3', content: Buffer.from('0123456789') },
|
||||
...audioEntries,
|
||||
]);
|
||||
}
|
||||
|
||||
/** An overlay whose audio file is named by `href`, written into the OPF and the SMIL as given. */
|
||||
function makeNamedAudioArchive(href: string, audioEntries: ZipEntrySpec[]) {
|
||||
const archive = makeMediaOverlayArchive(SMIL_XML.replaceAll('../audio/ch1.mp3', `../${href}`), audioEntries);
|
||||
const opf = OPF_MEDIA_OVERLAY_XML.replace('href="audio/ch1.mp3"', `href="${href}"`);
|
||||
const opfEntry = archive.files.find((file) => file.path === 'OPS/content.opf')!;
|
||||
opfEntry.buffer.mockResolvedValue(Buffer.from(opf));
|
||||
return archive;
|
||||
}
|
||||
|
||||
function makeEpubArchive(options?: { navBufferError?: boolean; omitChapterFile?: boolean; lowerCasePaths?: boolean; opf?: string }) {
|
||||
const lower = options?.lowerCasePaths === true;
|
||||
const ops = lower ? 'ops' : 'OPS';
|
||||
@@ -216,6 +231,7 @@ describe('EpubService', () => {
|
||||
bookReadService.findPrimaryFilesByBookIds.mockResolvedValue([{ format: 'epub', absolutePath: '/books/book.epub', sizeBytes: null }]);
|
||||
libraryService.verifyUserAccess.mockResolvedValue(undefined);
|
||||
mockStat.mockResolvedValue({ mtimeMs: 100 } as Awaited<ReturnType<typeof stat>>);
|
||||
mockOpenRange.mockImplementation(async (_path, entry, start, end) => Readable.from([(await entry.buffer()).subarray(start, end + 1)]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -425,7 +441,56 @@ describe('EpubService', () => {
|
||||
expect(result.status).toBe(206);
|
||||
expect(result.contentRange).toBe('bytes 2-5/10');
|
||||
expect(result.contentType).toBe('audio/mpeg');
|
||||
expect(result.data).toEqual(Buffer.from('2345'));
|
||||
expect(result.size).toBe(10);
|
||||
expect(result.length).toBe(4);
|
||||
expect(mockOpenRange).toHaveBeenCalledWith('/books/book.epub', expect.objectContaining({ path: 'OPS/audio/ch1.mp3' }), 2, 5);
|
||||
expect(await readStream(result.body as Readable)).toEqual(Buffer.from('2345'));
|
||||
});
|
||||
|
||||
it('reads the audio as a stream rather than buffering the whole entry', async () => {
|
||||
const archive = makeMediaOverlayArchive();
|
||||
mockOpenFile.mockImplementation(() => Promise.resolve(archive as any));
|
||||
mockOpenRange.mockResolvedValue(Readable.from([Buffer.from('0123456789')]));
|
||||
|
||||
const result = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, undefined, user);
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.contentRange).toBeNull();
|
||||
expect(result.length).toBe(10);
|
||||
expect(mockOpenRange).toHaveBeenCalledWith('/books/book.epub', expect.objectContaining({ path: 'OPS/audio/ch1.mp3' }), 0, 9);
|
||||
expect(archive.files.find((file) => file.path === 'OPS/audio/ch1.mp3')!.buffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches the requested path to the playlist exactly as the router decoded it', async () => {
|
||||
mockOpenFile.mockImplementation(() =>
|
||||
Promise.resolve(makeNamedAudioArchive('audio/50%25%20off.mp3', [{ path: 'OPS/audio/50% off.mp3', content: 'percent' }]) as any),
|
||||
);
|
||||
|
||||
const playlist = await service.getMediaOverlayPlaylist(99, undefined, user);
|
||||
const result = await service.streamMediaOverlayFile(99, 'OPS/audio/50% off.mp3', undefined, undefined, user);
|
||||
|
||||
expect(playlist.resources).toEqual([{ href: 'OPS/audio/50% off.mp3', mediaType: 'audio/mpeg', size: 7 }]);
|
||||
expect(await readStream(result.body as Readable)).toEqual(Buffer.from('percent'));
|
||||
// Decoding the request again is what let an escaped spelling of the name through.
|
||||
await expect(service.streamMediaOverlayFile(99, 'OPS/audio/50%25%20off.mp3', undefined, undefined, user)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('serves the entry named by the href over one whose literal escape decodes to it', async () => {
|
||||
mockOpenFile.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
makeNamedAudioArchive('audio/track%201.mp3', [
|
||||
{ path: 'OPS/audio/track%201.mp3', content: 'literal escape' },
|
||||
{ path: 'OPS/audio/track 1.mp3', content: 'space' },
|
||||
]) as any,
|
||||
),
|
||||
);
|
||||
|
||||
const playlist = await service.getMediaOverlayPlaylist(99, undefined, user);
|
||||
const result = await service.streamMediaOverlayFile(99, 'OPS/audio/track 1.mp3', undefined, undefined, user);
|
||||
|
||||
expect(playlist.resources).toEqual([{ href: 'OPS/audio/track 1.mp3', mediaType: 'audio/mpeg', size: 5 }]);
|
||||
expect(result.size).toBe(5);
|
||||
expect(await readStream(result.body as Readable)).toEqual(Buffer.from('space'));
|
||||
});
|
||||
|
||||
// A player asks for many ranges of one clip. Each used to parse every SMIL in the book again.
|
||||
@@ -437,7 +502,7 @@ describe('EpubService', () => {
|
||||
const second = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=2-3', user);
|
||||
|
||||
expect(mockOpenFile.mock.calls.length - opensForFirst).toBe(1);
|
||||
expect(second.data).toEqual(Buffer.from('23'));
|
||||
expect(await readStream(second.body as Readable)).toEqual(Buffer.from('23'));
|
||||
});
|
||||
|
||||
it('answers a range past the end with 416 and ignores a header it cannot read', async () => {
|
||||
@@ -446,12 +511,23 @@ describe('EpubService', () => {
|
||||
const beyond = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=20-30', user);
|
||||
expect(beyond.status).toBe(416);
|
||||
expect(beyond.contentRange).toBe('bytes */10');
|
||||
expect(beyond.data.length).toBe(0);
|
||||
expect(beyond.length).toBe(0);
|
||||
expect(beyond.body).toEqual(Buffer.alloc(0));
|
||||
|
||||
const openPastEnd = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=10-', user);
|
||||
expect(openPastEnd.status).toBe(416);
|
||||
expect(openPastEnd.contentRange).toBe('bytes */10');
|
||||
|
||||
const openTail = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=7-', user);
|
||||
expect(openTail.status).toBe(206);
|
||||
expect(openTail.contentRange).toBe('bytes 7-9/10');
|
||||
expect(await readStream(openTail.body as Readable)).toEqual(Buffer.from('789'));
|
||||
|
||||
const unreadable = await service.streamMediaOverlayFile(99, 'OPS/audio/ch1.mp3', undefined, 'bytes=abc', user);
|
||||
expect(unreadable.status).toBe(200);
|
||||
expect(unreadable.contentRange).toBeNull();
|
||||
expect(unreadable.data.length).toBe(10);
|
||||
expect(unreadable.length).toBe(10);
|
||||
expect(await readStream(unreadable.body as Readable)).toEqual(Buffer.from('0123456789'));
|
||||
});
|
||||
|
||||
it('rejects media-overlay file requests for non-playlist resources', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { stat } from 'fs/promises';
|
||||
import type { Readable } from 'stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
@@ -7,8 +8,9 @@ import type { EpubBookInfo, EpubManifestItem, EpubMediaOverlayPlaylist, EpubSpin
|
||||
import { BookReadService } from '../../book/book-read.service';
|
||||
import { LibraryService } from '../../library/library.service';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { buildEpubMediaOverlayPlaylist, findEpubZipEntry, normalizeEpubZipPath } from './epub-media-overlay';
|
||||
import { buildEpubMediaOverlayPlaylist, cleanEpubZipPath, findEpubZipEntry } from './epub-media-overlay';
|
||||
import { parseEpubPackage } from './epub-package';
|
||||
import { openEpubZipEntryRange } from './epub-zip-entry-stream';
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
@@ -71,7 +73,9 @@ interface ByteRange {
|
||||
}
|
||||
|
||||
interface MediaOverlayFileResponse {
|
||||
data: Buffer;
|
||||
body: Readable | Buffer;
|
||||
/** Bytes in `body`, for Content-Length. */
|
||||
length: number;
|
||||
contentType: string;
|
||||
size: number;
|
||||
status: number;
|
||||
@@ -100,18 +104,6 @@ function getText(v: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInZip(files: unzipper.File[], path: string): unzipper.File | undefined {
|
||||
const clean = normalizeZipPath(path);
|
||||
const cleanLower = clean.toLowerCase();
|
||||
let ciMatch: unzipper.File | undefined;
|
||||
for (const file of files) {
|
||||
const fp = normalizeZipPath(file.path);
|
||||
if (fp === clean) return file;
|
||||
if (!ciMatch && fp.toLowerCase() === cleanLower) ciMatch = file;
|
||||
}
|
||||
return ciMatch;
|
||||
}
|
||||
|
||||
function safeDecodeURI(path: string): string {
|
||||
try {
|
||||
return decodeURI(path);
|
||||
@@ -193,7 +185,7 @@ function parseNcxNavPoints(parent: Record<string, unknown>, basePath: string): E
|
||||
async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
const zip = await unzipper.Open.file(epubPath);
|
||||
|
||||
const containerEntry = findInZip(zip.files, 'META-INF/container.xml');
|
||||
const containerEntry = findEpubZipEntry(zip.files, 'META-INF/container.xml');
|
||||
if (!containerEntry) throw new Error('Missing META-INF/container.xml');
|
||||
const containerDoc = xmlParser.parse(await containerEntry.buffer()) as Record<string, unknown>;
|
||||
|
||||
@@ -205,7 +197,7 @@ async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
|
||||
const rootPath = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : '';
|
||||
|
||||
const opfEntry = findInZip(zip.files, opfPath);
|
||||
const opfEntry = findEpubZipEntry(zip.files, opfPath);
|
||||
if (!opfEntry) throw new Error(`OPF not found: ${opfPath}`);
|
||||
const pkg = parseEpubPackage(await opfEntry.buffer());
|
||||
const manifestEl = pkg['manifest'] as Record<string, unknown> | undefined;
|
||||
@@ -221,7 +213,7 @@ async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
const properties = propertiesStr ? propertiesStr.split(/\s+/) : undefined;
|
||||
const mediaOverlay = item['@_media-overlay'];
|
||||
const fullHref = normalizeZipPath(resolveHref(relHref, rootPath).split('#')[0]);
|
||||
const size = findInZip(zip.files, fullHref)?.uncompressedSize ?? 0;
|
||||
const size = findEpubZipEntry(zip.files, fullHref)?.uncompressedSize ?? 0;
|
||||
const manifestItem: EpubManifestItem = {
|
||||
id,
|
||||
href: fullHref,
|
||||
@@ -268,7 +260,7 @@ async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
const navItem = manifest.find((m) => m.properties?.includes('nav'));
|
||||
if (navItem) {
|
||||
try {
|
||||
const navEntry = findInZip(zip.files, navItem.href);
|
||||
const navEntry = findEpubZipEntry(zip.files, navItem.href);
|
||||
if (navEntry) {
|
||||
const navDoc = xmlParser.parse(await navEntry.buffer()) as Record<string, unknown>;
|
||||
const navDir = navItem.href.includes('/') ? navItem.href.slice(0, navItem.href.lastIndexOf('/') + 1) : rootPath;
|
||||
@@ -293,7 +285,7 @@ async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
const ncxItem = manifest.find((m) => m.mediaType === 'application/x-dtbncx+xml');
|
||||
if (ncxItem) {
|
||||
try {
|
||||
const ncxEntry = findInZip(zip.files, ncxItem.href);
|
||||
const ncxEntry = findEpubZipEntry(zip.files, ncxItem.href);
|
||||
if (ncxEntry) {
|
||||
const ncxDoc = xmlParser.parse(await ncxEntry.buffer()) as Record<string, unknown>;
|
||||
const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.slice(0, ncxItem.href.lastIndexOf('/') + 1) : rootPath;
|
||||
@@ -307,7 +299,7 @@ async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
const optionalFiles = OPTIONAL_META_INF_FILES.filter((path) => findInZip(zip.files, path));
|
||||
const optionalFiles = OPTIONAL_META_INF_FILES.filter((path) => findEpubZipEntry(zip.files, path));
|
||||
|
||||
return { containerPath: opfPath, rootPath, spine, manifest, optionalFiles, toc, metadata, coverPath };
|
||||
}
|
||||
@@ -352,6 +344,11 @@ export class EpubService {
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
* `filePath` is a playlist resource href as the router decoded it, or as a Watch ticket holds it.
|
||||
* It is compared with the playlist's hrefs as it is, never decoded again, so a name holding a
|
||||
* literal `%` still names its own entry.
|
||||
*/
|
||||
async streamMediaOverlayFile(
|
||||
bookId: number,
|
||||
filePath: string,
|
||||
@@ -360,37 +357,41 @@ export class EpubService {
|
||||
user: RequestUser,
|
||||
): Promise<MediaOverlayFileResponse> {
|
||||
if (filePath.includes('..')) throw new ForbiddenException('Invalid path');
|
||||
const normalizedPath = normalizeEpubZipPath(filePath);
|
||||
if (!normalizedPath) throw new ForbiddenException('Invalid path');
|
||||
const requestedPath = cleanEpubZipPath(filePath);
|
||||
if (!requestedPath) throw new ForbiddenException('Invalid path');
|
||||
|
||||
const resolved = await this.resolveEpubFile(bookId, fileId, user);
|
||||
const cached = await this.getCachedEntry(resolved.absolutePath);
|
||||
const playlist = await this.cachedPlaylist(cached, resolved.absolutePath, bookId, resolved.fileId);
|
||||
const resource = playlist.resources.find((item) => item.href === normalizedPath);
|
||||
if (!resource) throw new NotFoundException(`Media-overlay resource not in playlist: ${normalizedPath}`);
|
||||
const resource = playlist.resources.find((item) => item.href === requestedPath);
|
||||
if (!resource) throw new NotFoundException(`Media-overlay resource not in playlist: ${requestedPath}`);
|
||||
|
||||
const zip = await unzipper.Open.file(resolved.absolutePath);
|
||||
const entry = findEpubZipEntry(zip.files, normalizedPath);
|
||||
if (!entry) throw new NotFoundException(`Entry not in archive: ${normalizedPath}`);
|
||||
const entry = findEpubZipEntry(zip.files, resource.href);
|
||||
if (!entry) throw new NotFoundException(`Entry not in archive: ${requestedPath}`);
|
||||
|
||||
const full = await entry.buffer();
|
||||
const range = this.parseRange(rangeHeader, full.length);
|
||||
const size = entry.uncompressedSize;
|
||||
const range = this.parseRange(rangeHeader, size);
|
||||
if (range === 'unsatisfiable') {
|
||||
return {
|
||||
data: Buffer.alloc(0),
|
||||
body: Buffer.alloc(0),
|
||||
length: 0,
|
||||
contentType: resource.mediaType,
|
||||
size: full.length,
|
||||
size,
|
||||
status: 416,
|
||||
contentRange: `bytes */${full.length}`,
|
||||
contentRange: `bytes */${size}`,
|
||||
};
|
||||
}
|
||||
const data = range ? full.subarray(range.start, range.end + 1) : full;
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? size - 1;
|
||||
const length = end - start + 1;
|
||||
return {
|
||||
data,
|
||||
body: length > 0 ? await openEpubZipEntryRange(resolved.absolutePath, entry, start, end) : Buffer.alloc(0),
|
||||
length,
|
||||
contentType: resource.mediaType,
|
||||
size: full.length,
|
||||
size,
|
||||
status: range ? 206 : 200,
|
||||
contentRange: range ? `bytes ${range.start}-${range.end}/${full.length}` : null,
|
||||
contentRange: range ? `bytes ${start}-${end}/${size}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -410,7 +411,7 @@ export class EpubService {
|
||||
throw new NotFoundException(`Entry not in archive: ${normalizedPath}`);
|
||||
}
|
||||
const zip = await unzipper.Open.file(epubPath);
|
||||
const entry = findInZip(zip.files, normalizedPath);
|
||||
const entry = findEpubZipEntry(zip.files, normalizedPath);
|
||||
if (!entry) throw new NotFoundException(`Entry not in archive: ${normalizedPath}`);
|
||||
|
||||
const manifestItem = cached.info.manifest.find((m) => m.href === normalizedPath);
|
||||
@@ -464,7 +465,8 @@ export class EpubService {
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(startRaw);
|
||||
end = endRaw ? Number(endRaw) : size - 1;
|
||||
// An open range takes its end from the size, so it can only be past the end, not malformed.
|
||||
end = endRaw ? Number(endRaw) : Math.max(start, size - 1);
|
||||
}
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) return null;
|
||||
if (start >= size) return 'unsatisfiable';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Headers, NotFoundException, Param, ParseIntPipe, Post, Res } from '@nestjs/common';
|
||||
import { RouteConfig } from '@nestjs/platform-fastify';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
@@ -45,6 +46,7 @@ export class WatchDownloadController {
|
||||
}
|
||||
|
||||
@Get('media-overlay/files/:index')
|
||||
@RouteConfig({ compress: false })
|
||||
@Public()
|
||||
async downloadMediaOverlayFile(
|
||||
@Param('index', ParseIntPipe) index: number,
|
||||
@@ -53,7 +55,7 @@ export class WatchDownloadController {
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const resource = await this.watchDownloads.authorizeMediaOverlay(authorization, index);
|
||||
const { data, contentType, size, status, contentRange } = await this.epubService.streamMediaOverlayFile(
|
||||
const { body, length, contentType, size, status, contentRange } = await this.epubService.streamMediaOverlayFile(
|
||||
resource.bookId,
|
||||
resource.href,
|
||||
resource.fileId,
|
||||
@@ -64,11 +66,11 @@ export class WatchDownloadController {
|
||||
reply.code(status);
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', data.length);
|
||||
reply.header('Content-Length', length);
|
||||
if (contentRange) reply.header('Content-Range', contentRange);
|
||||
if (!contentRange && size > 0) reply.header('X-Content-Length-Full', size);
|
||||
reply.header('Cache-Control', 'private, no-store');
|
||||
reply.send(data);
|
||||
reply.send(body);
|
||||
}
|
||||
|
||||
@Get('files/:fileId')
|
||||
|
||||
Reference in New Issue
Block a user