msg_viewer
This commit is contained in:
parent
b46301ed13
commit
30afde78cc
128 changed files with 5352 additions and 0 deletions
2
msg_viewer/__init__.py
Normal file
2
msg_viewer/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
43
msg_viewer/__manifest__.py
Normal file
43
msg_viewer/__manifest__.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
'name': 'MSG Viewer',
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'summary': 'MSG file viewer for Odoo',
|
||||
'description': """
|
||||
This module adds support for viewing MSG files directly in Odoo.
|
||||
It allows users to preview Outlook MSG files without downloading them.
|
||||
|
||||
It is based on the MSGViewer module by Markiian Karpa
|
||||
https://github.com/molotochok/msg-viewer.git
|
||||
""",
|
||||
'author': 'Bemade inc.',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'base',
|
||||
'web',
|
||||
'mail'
|
||||
],
|
||||
'data': [
|
||||
'views/msg_viewer_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'msg_viewer/static/src/js/msg_viewer.js',
|
||||
'msg_viewer/static/src/xml/msg_viewer.xml',
|
||||
# 'msg_viewer/static/src/css/msg_viewer.css',
|
||||
],
|
||||
'msg_viewer.assets': [
|
||||
# MSG Viewer library files
|
||||
'msg_viewer/static/src/lib/msg-viewer.js',
|
||||
'msg_viewer/static/src/lib/scripts/**/*',
|
||||
'msg_viewer/static/src/lib/components/**/*',
|
||||
'msg_viewer/static/src/lib/styles/**/*',
|
||||
],
|
||||
'web.assets_qweb': [
|
||||
'msg_viewer/static/src/xml/msg_viewer.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
1
msg_viewer/controllers/__init__.py
Normal file
1
msg_viewer/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import main
|
||||
88
msg_viewer/controllers/main.py
Normal file
88
msg_viewer/controllers/main.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import base64
|
||||
import logging
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.http import request, Response, Stream
|
||||
from odoo.tools import str2bool, file_open
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class MsgViewer(http.Controller):
|
||||
@http.route([
|
||||
'/msg_viewer/content',
|
||||
'/msg_viewer/content/<string:xmlid>',
|
||||
'/msg_viewer/content/<string:xmlid>/<string:filename>',
|
||||
'/msg_viewer/content/<int:id>',
|
||||
'/msg_viewer/content/<int:id>/<string:filename>',
|
||||
'/msg_viewer/content/<string:model>/<int:id>/<string:field>',
|
||||
'/msg_viewer/content/<string:model>/<int:id>/<string:field>/<string:filename>',
|
||||
], type='http', auth='public', readonly=True)
|
||||
def msg_content(self, xmlid=None, model='ir.attachment', id=None, field='raw',
|
||||
filename=None, filename_field='name', download=False, access_token=None):
|
||||
"""Serve the MSG file content.
|
||||
|
||||
This endpoint serves the raw MSG file which will be processed by the msg-viewer JavaScript library.
|
||||
"""
|
||||
try:
|
||||
record = request.env['ir.binary']._find_record(xmlid, model, id and int(id), access_token, field=field)
|
||||
|
||||
# Get the binary content
|
||||
content = record[field]
|
||||
if not content:
|
||||
return request.not_found()
|
||||
|
||||
# Convert base64 to binary if needed
|
||||
if isinstance(content, str):
|
||||
content = base64.b64decode(content)
|
||||
|
||||
# Create a stream to serve the file
|
||||
stream = Stream.from_bytes(content)
|
||||
stream.type = 'application/vnd.ms-outlook' # MIME type for .msg files
|
||||
|
||||
return stream.get_response(as_attachment=str2bool(download))
|
||||
|
||||
except (AccessError, UserError):
|
||||
return request.not_found()
|
||||
except Exception as e:
|
||||
_logger.exception("Error while serving MSG file")
|
||||
return Response(
|
||||
f"Error serving MSG file: {str(e)}",
|
||||
status=500
|
||||
)
|
||||
|
||||
@http.route('/msg_viewer/content', type='http', auth='user')
|
||||
def get_msg_content(self, model, field, id, **kwargs):
|
||||
"""Get the content of a MSG file."""
|
||||
record = request.env[model].browse(int(id))
|
||||
if not record.exists() or not hasattr(record, field):
|
||||
return Response(status=404)
|
||||
|
||||
field_value = getattr(record, field)
|
||||
if not field_value:
|
||||
return Response(status=404)
|
||||
|
||||
return Response(
|
||||
field_value,
|
||||
headers=[
|
||||
('Content-Type', 'application/vnd.ms-outlook'),
|
||||
('Content-Disposition', f'inline; filename="{record.name}"'),
|
||||
]
|
||||
)
|
||||
|
||||
@http.route('/msg_viewer/static/lib/<path:path>', type='http', auth='public')
|
||||
def get_static_lib(self, path):
|
||||
"""Serve static files from the lib directory."""
|
||||
addon_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
file_path = os.path.join(addon_path, 'static', 'src', 'lib', path)
|
||||
|
||||
try:
|
||||
with file_open(file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
except (IOError, OSError):
|
||||
return Response(status=404)
|
||||
|
||||
content_type = 'text/javascript' if path.endswith('.js') else 'text/css' if path.endswith('.css') else 'text/html'
|
||||
return Response(content, content_type=content_type)
|
||||
1
msg_viewer/models/__init__.py
Normal file
1
msg_viewer/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import ir_attachment
|
||||
26
msg_viewer/models/ir_attachment.py
Normal file
26
msg_viewer/models/ir_attachment.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from odoo import models, fields, api
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = 'ir.attachment'
|
||||
|
||||
is_msg_file = fields.Boolean(
|
||||
string='Is MSG File',
|
||||
compute='_compute_is_msg_file',
|
||||
store=True,
|
||||
help='Technical field to identify MSG files'
|
||||
)
|
||||
|
||||
@api.depends('mimetype', 'name')
|
||||
def _compute_is_msg_file(self):
|
||||
for attachment in self:
|
||||
attachment.is_msg_file = (
|
||||
attachment.mimetype == 'application/vnd.ms-outlook' or
|
||||
(attachment.name and attachment.name.lower().endswith('.msg'))
|
||||
)
|
||||
|
||||
def _get_mimetype(self):
|
||||
"""Override to add MSG file mimetype detection."""
|
||||
mimetype = super()._get_mimetype()
|
||||
if not mimetype and self.name and self.name.lower().endswith('.msg'):
|
||||
return 'application/vnd.ms-outlook'
|
||||
return mimetype
|
||||
12
msg_viewer/static/src/css/msg_viewer.css
Normal file
12
msg_viewer/static/src/css/msg_viewer.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.o_field_msg_viewer_container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.o_field_msg_viewer_container iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
88
msg_viewer/static/src/js/msg_viewer.js
Normal file
88
msg_viewer/static/src/js/msg_viewer.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { FileUploader } from "@web/views/fields/file_handler";
|
||||
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
class MsgViewerField extends Component {
|
||||
static template = "web.MsgViewerField";
|
||||
static components = {
|
||||
FileUploader,
|
||||
};
|
||||
static props = {
|
||||
...standardFieldProps,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
isValid: true,
|
||||
objectUrl: "",
|
||||
showViewer: false,
|
||||
});
|
||||
onWillUpdateProps((nextProps) => {
|
||||
if (nextProps.readonly) {
|
||||
this.state.objectUrl = "";
|
||||
this.state.showViewer = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get url() {
|
||||
if (!this.state.isValid || !this.props.value) {
|
||||
return null;
|
||||
}
|
||||
const file = encodeURIComponent(
|
||||
this.state.objectUrl ||
|
||||
url("/msg_viewer/content", {
|
||||
model: this.props.record.resModel,
|
||||
field: this.props.name,
|
||||
id: this.props.record.resId,
|
||||
})
|
||||
);
|
||||
return `/msg_viewer/static/src/lib/index.html?file=${file}`;
|
||||
}
|
||||
|
||||
async onFileUploaded(file) {
|
||||
this.state.objectUrl = URL.createObjectURL(file);
|
||||
await this.props.update(file);
|
||||
}
|
||||
|
||||
async onFileRemove() {
|
||||
if (this.state.objectUrl) {
|
||||
URL.revokeObjectURL(this.state.objectUrl);
|
||||
this.state.objectUrl = "";
|
||||
}
|
||||
this.state.showViewer = false;
|
||||
await this.props.update(false);
|
||||
}
|
||||
|
||||
openMsgViewer() {
|
||||
this.state.showViewer = true;
|
||||
}
|
||||
|
||||
onLoadFailed() {
|
||||
this.notification.add(
|
||||
_t("Could not display the selected MSG file."),
|
||||
{
|
||||
type: "danger",
|
||||
}
|
||||
);
|
||||
this.state.isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const msgViewerField = {
|
||||
component: MsgViewerField,
|
||||
displayName: _t("MSG Viewer"),
|
||||
supportedTypes: ["binary"],
|
||||
extractProps: ({ props }) => ({
|
||||
acceptedFileExtensions: ".msg",
|
||||
}),
|
||||
};
|
||||
|
||||
registry.category("fields").add("msg_viewer", msgViewerField);
|
||||
12
msg_viewer/static/src/lib/components/attachment/index.html
Normal file
12
msg_viewer/static/src/lib/components/attachment/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<a class="btn btn-2 msg-attach-btn" href="{{ url }}" download="{{ name }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="50px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="#e8eaed">
|
||||
<path d="M320-240h320v-80H320v80Zm0-160h320v-80H320v80ZM240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z"/>
|
||||
</svg>
|
||||
<div class="flex-col">
|
||||
<span>{{ name }}</span>
|
||||
<span class="color-text-2">{{ size }}</span>
|
||||
</div>
|
||||
</a>
|
||||
30
msg_viewer/static/src/lib/components/attachment/index.ts
Normal file
30
msg_viewer/static/src/lib/components/attachment/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Message } from "../../scripts/msg/types/message";
|
||||
import { bytesWithUnits } from "../../scripts/utils/file-size-util";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function attachmentsFragment(message: Message): DocumentFragment {
|
||||
const attachments = message.attachments
|
||||
.filter(attachment => attachment.content)
|
||||
.map(attachment => {
|
||||
const blob = new Blob([attachment.content], { type: 'application/octet-stream' });
|
||||
|
||||
const model: AttachmentViewModel = {
|
||||
name: attachment.displayName,
|
||||
size: bytesWithUnits(attachment.content.byteLength).toString(),
|
||||
url: URL.createObjectURL(blob)
|
||||
};
|
||||
|
||||
return createFragmentFromTemplate(template, model);
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...attachments);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
interface AttachmentViewModel {
|
||||
name: string,
|
||||
size: string,
|
||||
url: string
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.msg-attach-btn {
|
||||
background-color: #1d5c56;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<button class="btn btn-2 embedded-msg-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="50px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="#e8eaed">
|
||||
<path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm320-280L160-640v400h640v-400L480-440Zm0-80 320-200H160l320 200ZM160-640v-80 480-400Z"/>
|
||||
</svg>
|
||||
<span>{{ name }}</span>
|
||||
</button>
|
||||
26
msg_viewer/static/src/lib/components/embedded-msg/index.ts
Normal file
26
msg_viewer/static/src/lib/components/embedded-msg/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry";
|
||||
import type { Message } from "../../scripts/msg/types/message";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function embeddedMsgsFragment(message: Message, onClick: (entry: DirectoryEntry) => void): DocumentFragment {
|
||||
const elements = message.attachments
|
||||
.filter(attachment => attachment.embeddedMsgObj)
|
||||
.map(attachment => {
|
||||
const model: EmbeddedMsgViewModel = {
|
||||
name: attachment.displayName
|
||||
};
|
||||
|
||||
const fragment = createFragmentFromTemplate(template, model);
|
||||
fragment.children[0].addEventListener("click", () => onClick(attachment.embeddedMsgObj));
|
||||
return fragment;
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...elements);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
interface EmbeddedMsgViewModel {
|
||||
name: string,
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.embedded-msg-btn {
|
||||
background-color: rgb(74, 126, 45);
|
||||
}
|
||||
1
msg_viewer/static/src/lib/components/error/index.html
Normal file
1
msg_viewer/static/src/lib/components/error/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<span class="msg-error">{{ error }}</span>
|
||||
6
msg_viewer/static/src/lib/components/error/index.ts
Normal file
6
msg_viewer/static/src/lib/components/error/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function errorFragment(error: string): DocumentFragment {
|
||||
return createFragmentFromTemplate(template, { error });
|
||||
}
|
||||
4
msg_viewer/static/src/lib/components/error/styles.css
Normal file
4
msg_viewer/static/src/lib/components/error/styles.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.msg-error {
|
||||
padding: 20px;
|
||||
color: #fe5e5e;
|
||||
}
|
||||
33
msg_viewer/static/src/lib/components/message/index.html
Normal file
33
msg_viewer/static/src/lib/components/message/index.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<div class="msg flex-col">
|
||||
<div class="msg-header">
|
||||
<div class="flex-b">
|
||||
<h2>{{ title }}</h2>
|
||||
<button class="btn msg-close-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="35px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="#fff">
|
||||
<path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="msg-divider"></div>
|
||||
<div class="flex-col">
|
||||
<span class="msg-name"><span class="color-text-2">From:</span> {{ name }}</span>
|
||||
<span class="msg-date color-text-2">{{ date }}</span>
|
||||
</div>
|
||||
<div class="msg-recip-section">
|
||||
<span class="color-text-2">To:</span><div class="msg-recip-to"></div>
|
||||
<span class="color-text-2">Cc:</span><div class="msg-recip-cc"></div>
|
||||
</div>
|
||||
<div class="msg-attach-section flex-col">
|
||||
<span class="color-text-2">Attachments:</span>
|
||||
<div class="msg-attachs"></div>
|
||||
</div>
|
||||
<div class="msg-embedded-section flex-col">
|
||||
<span class="color-text-2">Embedded .msg files:</span>
|
||||
<div class="msg-embedded-msgs"></div>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="msg-content">{{ rawContent }}</pre>
|
||||
</div>
|
||||
79
msg_viewer/static/src/lib/components/message/index.ts
Normal file
79
msg_viewer/static/src/lib/components/message/index.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry";
|
||||
import type { Message, MessageContent } from "../../scripts/msg/types/message";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import { attachmentsFragment } from "../attachment";
|
||||
import { embeddedMsgsFragment } from "../embedded-msg";
|
||||
import { recipientsFragments } from "../recipient";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function messageFragment(message: Message, renderDir: (dir: DirectoryEntry) => void): DocumentFragment {
|
||||
const [toRecip, ccRecip] = recipientsFragments(message);
|
||||
|
||||
const model: MessageViewModel = {
|
||||
title: message.content.subject,
|
||||
name: getName(message.content),
|
||||
date: getDate(message.content),
|
||||
rawContent: message.content.body,
|
||||
};
|
||||
|
||||
const container = createFragmentFromTemplate(template, model);
|
||||
addFragmentToContainer(container, ".msg-attachs", attachmentsFragment(message), false);
|
||||
addFragmentToContainer(container, ".msg-embedded-msgs", embeddedMsgsFragment(message, renderDir), true);
|
||||
addFragmentToContainer(container, ".msg-recip-to", toRecip, false);
|
||||
addFragmentToContainer(container, ".msg-recip-cc", ccRecip, false);
|
||||
|
||||
container.querySelector(".msg-close-btn")?.addEventListener("click", (e) => {
|
||||
const $btn = e.currentTarget as HTMLElement;
|
||||
const $msg = $btn.closest(".msg")!;
|
||||
const $container = $msg.parentElement!;
|
||||
$msg.remove();
|
||||
|
||||
const lastChild = $container.lastChild as HTMLElement;
|
||||
if (lastChild) {
|
||||
lastChild.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function addFragmentToContainer(container: DocumentFragment, className: string, fragment: DocumentFragment, removeIfEmpty: boolean) {
|
||||
const element = container.querySelector(className);
|
||||
if (!element) return;
|
||||
|
||||
if (fragment.children.length === 0 && removeIfEmpty) {
|
||||
element.parentElement?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
element.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function getName(content: MessageContent): string {
|
||||
let name = content.senderName ?? "";
|
||||
if (content.senderEmail) {
|
||||
name += ` <${content.senderEmail}>`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function getDate(content: MessageContent): string {
|
||||
return content.date?.toLocaleString('en-US', {
|
||||
weekday: "short",
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short"
|
||||
}) ?? "";
|
||||
}
|
||||
|
||||
interface MessageViewModel {
|
||||
title: string,
|
||||
name: string,
|
||||
date: string,
|
||||
rawContent: string,
|
||||
}
|
||||
50
msg_viewer/static/src/lib/components/message/styles.css
Normal file
50
msg_viewer/static/src/lib/components/message/styles.css
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
.msg {
|
||||
min-height: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
--h-padding: 16px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: var(--h-padding) var(--h-padding) 0;
|
||||
}
|
||||
|
||||
.msg-close-btn {
|
||||
padding: 0;
|
||||
background-color: #b42020;
|
||||
}
|
||||
.msg-close-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg-divider {
|
||||
margin: 0px calc(-1 * var(--h-padding));
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
.msg-date {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.msg-recip-section {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
column-gap: 5px;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
background: #24262b;
|
||||
padding: 15px 20px;
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg-attachs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<span>{{ name }} {{ email }}; </span>
|
||||
48
msg_viewer/static/src/lib/components/recipient/index.ts
Normal file
48
msg_viewer/static/src/lib/components/recipient/index.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { Message, Recipient } from "../../scripts/msg/types/message";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function recipientsFragments(message: Message): DocumentFragment[] {
|
||||
const toRecipients = toSet(message.content.toRecipients);
|
||||
const ccRecipients = toSet(message.content.ccRecipients);
|
||||
|
||||
const to = [];
|
||||
const cc = [];
|
||||
for (const recipient of message.recipients) {
|
||||
if (toRecipients.has(recipient.name)) {
|
||||
to.push(recipient);
|
||||
} else if (ccRecipients.has(recipient.name)) {
|
||||
cc.push(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
return [recipientHTML(to), recipientHTML(cc)];
|
||||
}
|
||||
|
||||
function recipientHTML(recipients: Recipient[]): DocumentFragment {
|
||||
const recipFragments = recipients.map(recipient => {
|
||||
const model: RecipientViewModel = {
|
||||
name: recipient.name,
|
||||
email: recipient.email ? `<${recipient.email}>` : ""
|
||||
};
|
||||
|
||||
return createFragmentFromTemplate(template, model);
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...recipFragments);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function toSet(recipStr: string) {
|
||||
if (!recipStr) return new Set();
|
||||
|
||||
return new Set(recipStr.endsWith('\x00')
|
||||
? recipStr.slice(0, -1).split("; ")
|
||||
: recipStr.split("; "));
|
||||
}
|
||||
|
||||
interface RecipientViewModel {
|
||||
name: string,
|
||||
email: string
|
||||
}
|
||||
5
msg_viewer/static/src/lib/components/styles.css
Normal file
5
msg_viewer/static/src/lib/components/styles.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@import "./message/styles.css";
|
||||
@import "./recipient/styles.css";
|
||||
@import "./attachment/styles.css";
|
||||
@import "./embedded-msg/styles.css";
|
||||
@import "./error/styles.css";
|
||||
BIN
msg_viewer/static/src/lib/favicon.ico
Normal file
BIN
msg_viewer/static/src/lib/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
50
msg_viewer/static/src/lib/index.html
Normal file
50
msg_viewer/static/src/lib/index.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-G1KRSXZNWZ"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-G1KRSXZNWZ');
|
||||
</script>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
|
||||
<title>Free in-browser .msg viewer</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="description" content="Read .msg file in your browser for free without sending any data to an external server.">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" href="./styles/styles.css">
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="info" class="flex-col">
|
||||
<div class="flex-col info-content">
|
||||
<h1>.msg viewer</h1>
|
||||
<p>This tool allows you to easily view the content of the Outlook .msg file.</p>
|
||||
<ul>
|
||||
<li>Free</li>
|
||||
<li>In browser</li>
|
||||
<li>Fast</li>
|
||||
<li>Secure</li>
|
||||
<li>No server</li>
|
||||
<li>No data sharing</li>
|
||||
</ul>
|
||||
<label for="file" class="btn">Select .msg file</label>
|
||||
<input type="file" id="file" name="file" accept=".msg" hidden />
|
||||
</div>
|
||||
<div class="info-footer">
|
||||
<div>Source code can be found on <a class="footer-link" href="https://github.com/molotochok/msg-viewer">Github</a></div>
|
||||
<div>If you wish to support me, you can buy me a <a class="footer-link" href="https://buymeacoffee.com/markian98f">coffee</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./scripts/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
184
msg_viewer/static/src/lib/msg-viewer-main/.gitignore
vendored
Normal file
184
msg_viewer/static/src/lib/msg-viewer-main/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Contains input and output resources
|
||||
/resources
|
||||
|
||||
# Contains built logic
|
||||
/build
|
||||
|
||||
# Contains Documentation which might be large in some cases
|
||||
/documentation
|
||||
21
msg_viewer/static/src/lib/msg-viewer-main/LICENSE
Normal file
21
msg_viewer/static/src/lib/msg-viewer-main/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Markiian Karpa
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
36
msg_viewer/static/src/lib/msg-viewer-main/README.md
Normal file
36
msg_viewer/static/src/lib/msg-viewer-main/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<div align="center">
|
||||
<img src="https://github.com/user-attachments/assets/f065cc3a-c40b-4917-ac51-006cfbc78f0f" alt="Icon"/>
|
||||
<p><strong>https://msg-viewer.pages.dev/</strong><p>
|
||||
</div>
|
||||
|
||||
### Description
|
||||
This tool allows you to read Outlook `.msg` file in your browser without sending any data to an external server. It is extremely fast, works on any device as long as you have an Internet connection to open the page.
|
||||
|
||||
### Motivation
|
||||
At work, I needed to read a .msg file. Since I use a Mac, I quickly realized that this task wasn't straightforward. The official Outlook app on Mac doesn’t support .msg files and instead expects .eml files. Due to sensitive data and company policies, I couldn't download any external software or use a website that uploads files to an external server. That’s why I decided to create my own solution to read the file. Here are the key functional requirements I set for myself while working on this project:
|
||||
- It must display the file’s content;
|
||||
- It must not rely on any server;
|
||||
- It must be fast.
|
||||
|
||||
### Features
|
||||
- Free (Open Source);
|
||||
- No Server, no data sharing;
|
||||
- Extremely Fast;
|
||||
- Works on any device that can open the page.
|
||||
|
||||
### Development
|
||||
#### Build
|
||||
The project uses Bun. To build it simply run:
|
||||
```
|
||||
bun .\build.ts
|
||||
```
|
||||
The command will put a final HTML in `build` folder.
|
||||
|
||||
#### Branches
|
||||
The page is hosted via Cloudfare. There are multiple branches for the preview and production.
|
||||
Branches:
|
||||
- dev - for development and corresponds to Preview in Cloudfare
|
||||
- main - for production and corresponds to Production in Cloudfare
|
||||
|
||||
### Support
|
||||
If you wish to support me you can by me a [coffee](https://buymeacoffee.com/markian98f).
|
||||
78
msg_viewer/static/src/lib/msg-viewer-main/build.ts
Normal file
78
msg_viewer/static/src/lib/msg-viewer-main/build.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import html from 'bun-plugin-html';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const SOURCE_DIR = "./lib";
|
||||
const DEST_DIR = "./build";
|
||||
|
||||
try {
|
||||
await clearDestDir();
|
||||
await createDestDir();
|
||||
await copyResourceFiles();
|
||||
await build();
|
||||
} catch(err) {
|
||||
console.error("Failed to build. Error: ", err);
|
||||
}
|
||||
|
||||
async function copyResourceFiles() {
|
||||
const resourceDir = path.join(SOURCE_DIR, "resources");
|
||||
console.log(`Copying resources from "${resourceDir}" to "${DEST_DIR}".`);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(resourceDir);
|
||||
for (const file of files) {
|
||||
try {
|
||||
await fs.copyFile(path.join(resourceDir, file), path.join(DEST_DIR, file));
|
||||
console.log(`File "${file}" copied successfully!`);
|
||||
} catch(err) {
|
||||
console.error(`Failed to copy file "${file}".`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Resource files copied successfully!\n");
|
||||
} catch (err) {
|
||||
console.error(`Failed to read resource directory "${resourceDir}".`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function build() {
|
||||
console.log("Building...");
|
||||
|
||||
try {
|
||||
await Bun.build({
|
||||
entrypoints: ["./lib/index.html"],
|
||||
outdir: DEST_DIR,
|
||||
minify: true,
|
||||
plugins: [
|
||||
html({ inline: true }),
|
||||
],
|
||||
});
|
||||
console.log("Build succeeded!\n");
|
||||
} catch(err) {
|
||||
console.error("Failed to build.");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDestDir() {
|
||||
console.log(`Clearing destination folder "${DEST_DIR}".`);
|
||||
try {
|
||||
await fs.rm(DEST_DIR, { recursive: true, force: true });
|
||||
console.log(`Destination folder "${DEST_DIR}" cleared successfully!\n`);
|
||||
} catch(err) {
|
||||
console.error(`Failed to clear destination folder "${DEST_DIR}". Error: `, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDestDir(){
|
||||
console.log(`Creating destination folder "${DEST_DIR}".`);
|
||||
try {
|
||||
await fs.mkdir(DEST_DIR, { recursive: true });
|
||||
console.log(`Successfully created destination folder "${DEST_DIR}".\n`);
|
||||
} catch(err) {
|
||||
console.error(`Failed to create destination folder "${DEST_DIR}".`);
|
||||
}
|
||||
}
|
||||
BIN
msg_viewer/static/src/lib/msg-viewer-main/bun.lockb
Normal file
BIN
msg_viewer/static/src/lib/msg-viewer-main/bun.lockb
Normal file
Binary file not shown.
8
msg_viewer/static/src/lib/msg-viewer-main/declarations.d.ts
vendored
Normal file
8
msg_viewer/static/src/lib/msg-viewer-main/declarations.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
declare module "*.html" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
gtag: (event: string, type: string, args: { [key: string]: any }) => void;
|
||||
}
|
||||
12
msg_viewer/static/src/lib/msg-viewer-main/icon.svg
Normal file
12
msg_viewer/static/src/lib/msg-viewer-main/icon.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="557.116 172.116 84.748 87.168" width="84.748" height="87.168" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1, 0, 0, 1, 341.2548828125, -3.531329870223999)">
|
||||
<rect x="219.447" y="215.129" width="77.007" height="38.066" style="stroke: rgb(0, 0, 0); stroke-width: 0px; fill: rgb(62, 130, 191);"/>
|
||||
<rect x="223.602" y="196.095" width="12.033" height="19.908" style="stroke: rgb(0, 0, 0); stroke-width: 0px; fill: rgb(62, 130, 191);"/>
|
||||
<rect x="281.905" y="196.205" width="12.033" height="19.908" style="stroke: rgb(0, 0, 0); stroke-width: 0px; fill: rgb(62, 130, 191);"/>
|
||||
<rect x="232.38" y="206.132" width="14.439" height="13.564" style="stroke: rgb(0, 0, 0); stroke-width: 0px; fill: rgb(62, 130, 191);"/>
|
||||
<rect x="270.072" y="204.733" width="14.439" height="13.564" style="stroke: rgb(0, 0, 0); stroke-width: 0px; fill: rgb(62, 130, 191);"/>
|
||||
</g>
|
||||
<path style="stroke-width: 0px; stroke: rgb(152, 93, 93); fill: rgb(78, 89, 111);" d="M 562.832 185.879 C 567.551 192.96 574.169 195.726 581.782 199.53 C 582.674 199.976 583.64 201.552 584.511 202.42 C 585.881 203.79 588.013 204.636 589.33 205.955 C 589.875 206.5 590.775 207.773 591.578 208.042 C 592.526 208.359 593.259 210.18 594.148 210.771 C 596.104 212.076 599.534 213.688 602.016 212.861 C 604.761 211.945 606.217 209.605 608.922 208.523 C 610.789 207.779 614.557 206.175 615.666 204.51 C 616.292 203.57 617.522 202.033 618.718 201.136 C 623.109 197.843 626.701 194.44 630.604 190.537 C 633.147 187.993 635.772 185.984 637.187 183.15 C 637.539 182.444 637.045 180.437 636.706 180.099 C 631.893 175.285 622.301 178.331 615.989 178.331 C 602.8 178.331 589.496 178.17 576.321 178.17 C 573.205 178.17 569.23 177.577 566.203 178.331 C 565.606 178.482 563.594 178.729 563.312 179.296 C 563.092 179.739 563.31 180.268 563.151 180.741 C 562.993 181.222 561.368 184.096 562.348 185.076 C 562.517 185.245 563.954 186.336 563.954 186.363"/>
|
||||
<path d="M 565.59 259.284 C 563.259 259.284 561.263 258.217 559.606 256.082 C 557.946 253.949 557.116 251.384 557.116 248.389 L 557.116 183.01 C 557.116 180.015 557.946 177.448 559.606 175.315 C 561.263 173.182 563.259 172.116 565.59 172.116 L 605.051 172.116 L 633.389 172.116 C 635.721 172.116 637.714 173.182 639.374 175.315 C 641.034 177.448 641.864 180.015 641.864 183.01 L 641.864 248.389 C 641.864 251.384 641.034 253.949 639.374 256.082 C 637.714 258.217 635.721 259.284 633.389 259.284 L 565.59 259.284 Z M 599.488 221.147 L 565.59 193.905 L 565.59 248.389 L 633.389 248.389 L 633.389 193.905 L 599.488 221.147 Z M 599.488 210.252 L 633.389 183.01 L 565.59 183.01 L 599.488 210.252 Z M 565.59 193.905 L 565.59 183.01 L 565.59 248.389 L 565.59 193.905 Z" style="fill: rgb(36, 38, 43);"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
27
msg_viewer/static/src/lib/msg-viewer-main/jsconfig.json
Normal file
27
msg_viewer/static/src/lib/msg-viewer-main/jsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<a class="btn btn-2 msg-attach-btn" href="{{ url }}" download="{{ name }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="50px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="#e8eaed">
|
||||
<path d="M320-240h320v-80H320v80Zm0-160h320v-80H320v80ZM240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z"/>
|
||||
</svg>
|
||||
<div class="flex-col">
|
||||
<span>{{ name }}</span>
|
||||
<span class="color-text-2">{{ size }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { Message } from "../../scripts/msg/types/message";
|
||||
import { bytesWithUnits } from "../../scripts/utils/file-size-util";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function attachmentsFragment(message: Message): DocumentFragment {
|
||||
const attachments = message.attachments
|
||||
.filter(attachment => attachment.content)
|
||||
.map(attachment => {
|
||||
const blob = new Blob([attachment.content], { type: 'application/octet-stream' });
|
||||
|
||||
const model: AttachmentViewModel = {
|
||||
name: attachment.displayName,
|
||||
size: bytesWithUnits(attachment.content.byteLength).toString(),
|
||||
url: URL.createObjectURL(blob)
|
||||
};
|
||||
|
||||
return createFragmentFromTemplate(template, model);
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...attachments);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
interface AttachmentViewModel {
|
||||
name: string,
|
||||
size: string,
|
||||
url: string
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.msg-attach-btn {
|
||||
background-color: #1d5c56;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<button class="btn btn-2 embedded-msg-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="50px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="#e8eaed">
|
||||
<path d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm320-280L160-640v400h640v-400L480-440Zm0-80 320-200H160l320 200ZM160-640v-80 480-400Z"/>
|
||||
</svg>
|
||||
<span>{{ name }}</span>
|
||||
</button>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry";
|
||||
import type { Message } from "../../scripts/msg/types/message";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function embeddedMsgsFragment(message: Message, onClick: (entry: DirectoryEntry) => void): DocumentFragment {
|
||||
const elements = message.attachments
|
||||
.filter(attachment => attachment.embeddedMsgObj)
|
||||
.map(attachment => {
|
||||
const model: EmbeddedMsgViewModel = {
|
||||
name: attachment.displayName
|
||||
};
|
||||
|
||||
const fragment = createFragmentFromTemplate(template, model);
|
||||
fragment.children[0].addEventListener("click", () => onClick(attachment.embeddedMsgObj));
|
||||
return fragment;
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...elements);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
interface EmbeddedMsgViewModel {
|
||||
name: string,
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.embedded-msg-btn {
|
||||
background-color: rgb(74, 126, 45);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<span class="msg-error">{{ error }}</span>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function errorFragment(error: string): DocumentFragment {
|
||||
return createFragmentFromTemplate(template, { error });
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.msg-error {
|
||||
padding: 20px;
|
||||
color: #fe5e5e;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<div class="msg flex-col">
|
||||
<div class="msg-header">
|
||||
<div class="flex-b">
|
||||
<h2>{{ title }}</h2>
|
||||
<button class="btn msg-close-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="35px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="#fff">
|
||||
<path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="msg-divider"></div>
|
||||
<div class="flex-col">
|
||||
<span class="msg-name"><span class="color-text-2">From:</span> {{ name }}</span>
|
||||
<span class="msg-date color-text-2">{{ date }}</span>
|
||||
</div>
|
||||
<div class="msg-recip-section">
|
||||
<span class="color-text-2">To:</span><div class="msg-recip-to"></div>
|
||||
<span class="color-text-2">Cc:</span><div class="msg-recip-cc"></div>
|
||||
</div>
|
||||
<div class="msg-attach-section flex-col">
|
||||
<span class="color-text-2">Attachments:</span>
|
||||
<div class="msg-attachs"></div>
|
||||
</div>
|
||||
<div class="msg-embedded-section flex-col">
|
||||
<span class="color-text-2">Embedded .msg files:</span>
|
||||
<div class="msg-embedded-msgs"></div>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="msg-content">{{ rawContent }}</pre>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import type { DirectoryEntry } from "../../scripts/msg/compound-file/directory/types/directory-entry";
|
||||
import type { Message, MessageContent } from "../../scripts/msg/types/message";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import { attachmentsFragment } from "../attachment";
|
||||
import { embeddedMsgsFragment } from "../embedded-msg";
|
||||
import { recipientsFragments } from "../recipient";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function messageFragment(message: Message, renderDir: (dir: DirectoryEntry) => void): DocumentFragment {
|
||||
const [toRecip, ccRecip] = recipientsFragments(message);
|
||||
|
||||
const model: MessageViewModel = {
|
||||
title: message.content.subject,
|
||||
name: getName(message.content),
|
||||
date: getDate(message.content),
|
||||
rawContent: message.content.body,
|
||||
};
|
||||
|
||||
const container = createFragmentFromTemplate(template, model);
|
||||
addFragmentToContainer(container, ".msg-attachs", attachmentsFragment(message), false);
|
||||
addFragmentToContainer(container, ".msg-embedded-msgs", embeddedMsgsFragment(message, renderDir), true);
|
||||
addFragmentToContainer(container, ".msg-recip-to", toRecip, false);
|
||||
addFragmentToContainer(container, ".msg-recip-cc", ccRecip, false);
|
||||
|
||||
container.querySelector(".msg-close-btn")?.addEventListener("click", (e) => {
|
||||
const $btn = e.currentTarget as HTMLElement;
|
||||
const $msg = $btn.closest(".msg")!;
|
||||
const $container = $msg.parentElement!;
|
||||
$msg.remove();
|
||||
|
||||
const lastChild = $container.lastChild as HTMLElement;
|
||||
if (lastChild) {
|
||||
lastChild.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function addFragmentToContainer(container: DocumentFragment, className: string, fragment: DocumentFragment, removeIfEmpty: boolean) {
|
||||
const element = container.querySelector(className);
|
||||
if (!element) return;
|
||||
|
||||
if (fragment.children.length === 0 && removeIfEmpty) {
|
||||
element.parentElement?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
element.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function getName(content: MessageContent): string {
|
||||
let name = content.senderName ?? "";
|
||||
if (content.senderEmail) {
|
||||
name += ` <${content.senderEmail}>`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function getDate(content: MessageContent): string {
|
||||
return content.date?.toLocaleString('en-US', {
|
||||
weekday: "short",
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short"
|
||||
}) ?? "";
|
||||
}
|
||||
|
||||
interface MessageViewModel {
|
||||
title: string,
|
||||
name: string,
|
||||
date: string,
|
||||
rawContent: string,
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
.msg {
|
||||
min-height: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
--h-padding: 16px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: var(--h-padding) var(--h-padding) 0;
|
||||
}
|
||||
|
||||
.msg-close-btn {
|
||||
padding: 0;
|
||||
background-color: #b42020;
|
||||
}
|
||||
.msg-close-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg-divider {
|
||||
margin: 0px calc(-1 * var(--h-padding));
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
.msg-date {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.msg-recip-section {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
column-gap: 5px;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
background: #24262b;
|
||||
padding: 15px 20px;
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg-attachs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<span>{{ name }} {{ email }}; </span>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { Message, Recipient } from "../../scripts/msg/types/message";
|
||||
import { createFragmentFromTemplate } from "../../scripts/utils/html-template-util";
|
||||
import template from "./index.html" with { type: "text" };
|
||||
|
||||
export function recipientsFragments(message: Message): DocumentFragment[] {
|
||||
const toRecipients = toSet(message.content.toRecipients);
|
||||
const ccRecipients = toSet(message.content.ccRecipients);
|
||||
|
||||
const to = [];
|
||||
const cc = [];
|
||||
for (const recipient of message.recipients) {
|
||||
if (toRecipients.has(recipient.name)) {
|
||||
to.push(recipient);
|
||||
} else if (ccRecipients.has(recipient.name)) {
|
||||
cc.push(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
return [recipientHTML(to), recipientHTML(cc)];
|
||||
}
|
||||
|
||||
function recipientHTML(recipients: Recipient[]): DocumentFragment {
|
||||
const recipFragments = recipients.map(recipient => {
|
||||
const model: RecipientViewModel = {
|
||||
name: recipient.name,
|
||||
email: recipient.email ? `<${recipient.email}>` : ""
|
||||
};
|
||||
|
||||
return createFragmentFromTemplate(template, model);
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...recipFragments);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function toSet(recipStr: string) {
|
||||
if (!recipStr) return new Set();
|
||||
|
||||
return new Set(recipStr.endsWith('\x00')
|
||||
? recipStr.slice(0, -1).split("; ")
|
||||
: recipStr.split("; "));
|
||||
}
|
||||
|
||||
interface RecipientViewModel {
|
||||
name: string,
|
||||
email: string
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
@import "./message/styles.css";
|
||||
@import "./recipient/styles.css";
|
||||
@import "./attachment/styles.css";
|
||||
@import "./embedded-msg/styles.css";
|
||||
@import "./error/styles.css";
|
||||
BIN
msg_viewer/static/src/lib/msg-viewer-main/lib/favicon.ico
Normal file
BIN
msg_viewer/static/src/lib/msg-viewer-main/lib/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
50
msg_viewer/static/src/lib/msg-viewer-main/lib/index.html
Normal file
50
msg_viewer/static/src/lib/msg-viewer-main/lib/index.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-G1KRSXZNWZ"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-G1KRSXZNWZ');
|
||||
</script>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
|
||||
<title>Free in-browser .msg viewer</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="description" content="Read .msg file in your browser for free without sending any data to an external server.">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" href="./styles/styles.css">
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="info" class="flex-col">
|
||||
<div class="flex-col info-content">
|
||||
<h1>.msg viewer</h1>
|
||||
<p>This tool allows you to easily view the content of the Outlook .msg file.</p>
|
||||
<ul>
|
||||
<li>Free</li>
|
||||
<li>In browser</li>
|
||||
<li>Fast</li>
|
||||
<li>Secure</li>
|
||||
<li>No server</li>
|
||||
<li>No data sharing</li>
|
||||
</ul>
|
||||
<label for="file" class="btn">Select .msg file</label>
|
||||
<input type="file" id="file" name="file" accept=".msg" hidden />
|
||||
</div>
|
||||
<div class="info-footer">
|
||||
<div>Source code can be found on <a class="footer-link" href="https://github.com/molotochok/msg-viewer">Github</a></div>
|
||||
<div>If you wish to support me, you can buy me a <a class="footer-link" href="https://buymeacoffee.com/markian98f">coffee</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./scripts/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1 @@
|
|||
google-site-verification: google331b65fa04565532.html
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { messageFragment } from "../components/message";
|
||||
import { errorFragment } from "../components/error";
|
||||
import type { Message } from "./msg/types/message";
|
||||
import { parse, parseDir } from "@molotochok/msg-viewer";
|
||||
|
||||
const $file = document.getElementById("file")!;
|
||||
|
||||
$file.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target?.files?.length === 0) return;
|
||||
updateMessage(target.files!);
|
||||
});
|
||||
|
||||
// To reset the file input
|
||||
$file.addEventListener("click", (event) => (event.target as HTMLInputElement).value = "");
|
||||
|
||||
|
||||
const target = document.documentElement;
|
||||
target.addEventListener("dragover", (event) => event.preventDefault());
|
||||
target.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const files = event.dataTransfer!.files;
|
||||
if (files.length == 0) return;
|
||||
if (!files[0].name.endsWith(".msg")) return;
|
||||
|
||||
const $file = document.getElementById("file")! as HTMLInputElement;
|
||||
$file.files = files;
|
||||
updateMessage(files);
|
||||
});
|
||||
|
||||
async function updateMessage(files: FileList) {
|
||||
const arrayBuffer = await files[0].arrayBuffer();
|
||||
const $msg = document.getElementById("msg")!;
|
||||
renderMessage($msg,
|
||||
() => parse(new DataView(arrayBuffer)),
|
||||
(fragment) => $msg.replaceChildren(fragment)
|
||||
);
|
||||
}
|
||||
|
||||
function renderMessage($msg: HTMLElement, getMessage: () => Message, updateDom: (fragment: DocumentFragment) => void) {
|
||||
let fragment: DocumentFragment;
|
||||
try {
|
||||
const message = getMessage();
|
||||
fragment = messageFragment(message, dir => {
|
||||
renderMessage($msg,
|
||||
() => parseDir(message.file, dir),
|
||||
(fragment) => {
|
||||
for (let i = 0; i < $msg.children.length; i++) {
|
||||
const child = $msg.children[i] as HTMLElement;
|
||||
child.classList.add("hidden");
|
||||
};
|
||||
$msg.appendChild(fragment)
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
window.gtag('event', 'exception', { 'description': e, 'fatal': true });
|
||||
fragment = errorFragment(`An error occured during the parsing of the .msg file. Error: ${e}`);
|
||||
}
|
||||
|
||||
updateDom(fragment);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<div align="center">
|
||||
<img src="https://github.com/user-attachments/assets/f065cc3a-c40b-4917-ac51-006cfbc78f0f" alt="Icon"/>
|
||||
<p><strong>https://msg-viewer.pages.dev/</strong><p>
|
||||
</div>
|
||||
|
||||
### Description
|
||||
This library allows you to read Outlook `.msg` file in browser.
|
||||
|
||||
### Install
|
||||
Simply run this command in terminal:
|
||||
```
|
||||
npm i @molotochok/msg-viewer
|
||||
```
|
||||
|
||||
### Usage
|
||||
First import the library:
|
||||
|
||||
```js
|
||||
import { parse } from "@molotochok/msg-viewer";
|
||||
```
|
||||
|
||||
Then parse the input array buffer:
|
||||
```js
|
||||
const message = parse(new DataView(arrayBuffer));
|
||||
```
|
||||
|
||||
The result is an object that contains relevant information about the message. It's structure is defined [here](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/msg/types/message.d.ts#L1).
|
||||
|
||||
### Example
|
||||
Refer to this [page](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/index.ts#L34) for the example usage of this library in the live experience.
|
||||
|
||||
|
||||
### Repository
|
||||
You can find the source code on [Github](https://github.com/molotochok/msg-viewer).
|
||||
|
||||
### Support
|
||||
If you wish to support me you can by me a [coffee](https://buymeacoffee.com/markian98f).
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { getDifat } from "./difat";
|
||||
import { Directory } from "./directory/directory";
|
||||
import type { DirectoryEntry } from "./directory/types/directory-entry";
|
||||
import { getFat } from "./fat";
|
||||
import { getHeader, type Header } from "./header";
|
||||
import { getMiniFat } from "./mini-fat";
|
||||
import { streamSectorOffset } from "./util";
|
||||
|
||||
export class CompoundFile {
|
||||
constructor(
|
||||
public readonly view: DataView,
|
||||
public readonly header: Header,
|
||||
public readonly difat: number[],
|
||||
public readonly fat: number[],
|
||||
public readonly miniFat: number[],
|
||||
public readonly directory: Directory,
|
||||
) {}
|
||||
|
||||
static create(view: DataView) {
|
||||
const header = getHeader(view);
|
||||
const difat = getDifat(view, header);
|
||||
const fat = getFat(view, header, difat);
|
||||
const miniFat = getMiniFat(view, header, fat);
|
||||
const directory = Directory.getDirectory(view, header, fat);
|
||||
|
||||
return new CompoundFile(view, header, difat, fat, miniFat, directory);
|
||||
}
|
||||
|
||||
readStream(
|
||||
entry: DirectoryEntry,
|
||||
getDataAction: (offset: number, bytesToRead: number) => void,
|
||||
entrySize?: number,
|
||||
getHeaderAction?: (offset: number) => number
|
||||
) {
|
||||
let sector = entry.startingSectorLocation;
|
||||
if (sector >= 0xFFFFFFFE) return;
|
||||
|
||||
let sectorSize = this.header.sectorSize;
|
||||
let fat = this.fat;
|
||||
|
||||
if (entry.streamSize < this.header.miniStreamCutOffSize) {
|
||||
sectorSize = this.header.miniSectorSize;
|
||||
fat = this.miniFat;
|
||||
}
|
||||
|
||||
let offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations);
|
||||
let initialOffset = offset;
|
||||
|
||||
const headerSize = getHeaderAction ? getHeaderAction(offset) : 0;
|
||||
let streamSize = entry.streamSize - BigInt(headerSize);
|
||||
offset += headerSize;
|
||||
entrySize = entrySize ?? Math.min(Number(streamSize), this.header.miniSectorSize);
|
||||
|
||||
while(streamSize > 0) {
|
||||
if (offset - initialOffset >= sectorSize) {
|
||||
sector = fat[sector];
|
||||
if (sector >= 0xFFFFFFFE) break;
|
||||
|
||||
offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations);
|
||||
initialOffset = offset;
|
||||
}
|
||||
|
||||
let bytes = Math.min(entrySize, Number(streamSize));
|
||||
getDataAction(offset, bytes);
|
||||
|
||||
streamSize -= BigInt(bytes);
|
||||
offset += bytes;
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const obj = {
|
||||
header: this.header,
|
||||
difat: this.difat,
|
||||
fat: this.fat,
|
||||
miniFat:
|
||||
this.miniFat,
|
||||
directory:
|
||||
this.directory
|
||||
};
|
||||
|
||||
return JSON.stringify(obj, (_, v) => typeof v === 'bigint' ? v.toString() : v, "\t");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const TEXT_DECODER = new TextDecoder("UTF-16LE");
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { Header } from "./header";
|
||||
import { sectorOffset } from "./util";
|
||||
|
||||
/**
|
||||
* Returns an array of FAT Sector Locations which specify the FAT sector number.
|
||||
* DIFAT is used to find all FAT sectors.
|
||||
*/
|
||||
export function getDifat(view: DataView, header: Header): number[] {
|
||||
// - If Header Major Version is 3, there MUST be 127 fields specified to fill a 512-byte sector minus the "Next DIFAT Sector Location" field.
|
||||
// - If Header Major Version is 4, there MUST be 1,023 fields specified to fill a 4,096-byte sector minus the "Next DIFAT Sector Location" field.
|
||||
const fatNumber = header.majorVersion == 3 ? 127 : 1023;
|
||||
|
||||
const difat = Array.from(header.difat);
|
||||
|
||||
// Next DIFAT Sector Location (4 bytes): This field specifies the next sector number in the DIFAT chain of sectors. The first DIFAT sector is specified in the Header. The last DIFAT sector MUST set this field to ENDOFCHAIN (0xFFFFFFFE).
|
||||
let sector = header.firstDifatSectorLocation;
|
||||
while (sector < 0xFFFFFFFE) {
|
||||
let offset = sectorOffset(sector, header.sectorSize);
|
||||
|
||||
for (let i = 0; i < fatNumber; i++) {
|
||||
if (view.getUint32(offset, true) == 0xFFFFFFFF) {
|
||||
offset += (fatNumber - i) * 4;
|
||||
break;
|
||||
}
|
||||
|
||||
difat.push(view.getUint32(offset, true));
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
sector = view.getUint32(offset, true);
|
||||
}
|
||||
|
||||
return difat;
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { ColorFlag } from "./enums/color-flag";
|
||||
import type { DirectoryEntry } from "./types/directory-entry";
|
||||
import { ObjectType } from "./enums/object-type";
|
||||
import type { Header } from "../header";
|
||||
import { sectorOffset } from "../util";
|
||||
import { TEXT_DECODER } from "../constants/text-decoder";
|
||||
|
||||
export class Directory {
|
||||
constructor(
|
||||
public readonly entries: DirectoryEntry[],
|
||||
public readonly miniStreamLocations: number[]
|
||||
) {}
|
||||
|
||||
static getDirectory(view: DataView, header: Header, fat: number[]): Directory {
|
||||
const entrySize = 128;
|
||||
const entriesCount = header.sectorSize / entrySize;
|
||||
|
||||
const entries: DirectoryEntry[] = [];
|
||||
|
||||
let sector = header.firstDirSectorLocation;
|
||||
while (sector < 0xFFFFFFFE) {
|
||||
let offset = sectorOffset(sector, header.sectorSize);
|
||||
|
||||
for (let i = 0; i < entriesCount; i++) {
|
||||
entries.push(this.directoryEntry(view, offset));
|
||||
|
||||
offset += entrySize;
|
||||
}
|
||||
|
||||
sector = fat[sector];
|
||||
}
|
||||
|
||||
return new Directory(
|
||||
entries,
|
||||
this.getMiniStreamLocations(entries[0].startingSectorLocation, fat),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses a Red-Black Tree to find the entry with the given name.
|
||||
*/
|
||||
get(name: string, root: number, deep: boolean): DirectoryEntry | null {
|
||||
if (root < 0 || root >= this.entries.length) return null;
|
||||
|
||||
const entry = this.entries[root];
|
||||
if (!entry) return null;
|
||||
|
||||
const diff = this.compareName(name, entry.entryName);
|
||||
|
||||
if (diff < 0) {
|
||||
const left = this.get(name, entry.leftSiblingId, deep);
|
||||
if (left) return left;
|
||||
}else if (diff > 0) {
|
||||
const right = this.get(name, entry.rightSiblingId, deep);
|
||||
if (right) return right;
|
||||
} else {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return deep ? this.get(name, entry.childId, deep) : null;
|
||||
}
|
||||
|
||||
private static getMiniStreamLocations(sector: number, fat: number[]): number[] {
|
||||
const locations: number[] = [];
|
||||
|
||||
while(sector < 0xFFFFFFFE) {
|
||||
locations.push(sector);
|
||||
sector = fat[sector];
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
private static directoryEntry(view: DataView, offset: number): DirectoryEntry {
|
||||
const entryNameLength = view.getUint16(offset + 64, true);
|
||||
const entryName = entryNameLength > 0
|
||||
? TEXT_DECODER.decode(new DataView(view.buffer, offset, entryNameLength - 2))
|
||||
: "";
|
||||
offset += 66;
|
||||
|
||||
const objectType = view.getUint8(offset) as ObjectType;
|
||||
offset += 1;
|
||||
|
||||
const colorFlag = view.getUint8(offset) as ColorFlag;
|
||||
offset += 1;
|
||||
|
||||
const leftSiblingId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const rightSiblingId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const childId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const clsid = "";
|
||||
offset += 16;
|
||||
|
||||
const stateBits = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const creationTime = view.getBigUint64(offset, true);
|
||||
offset += 8;
|
||||
|
||||
const modifiedTime = view.getBigUint64(offset, true);
|
||||
offset += 8;
|
||||
|
||||
const startingSectorLocation = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const streamSize = view.getBigUint64(offset, true);
|
||||
offset += 8;
|
||||
|
||||
return {
|
||||
entryName,
|
||||
entryNameLength,
|
||||
objectType,
|
||||
colorFlag,
|
||||
leftSiblingId,
|
||||
rightSiblingId,
|
||||
childId,
|
||||
clsid,
|
||||
stateBits,
|
||||
creationTime,
|
||||
modifiedTime,
|
||||
startingSectorLocation,
|
||||
streamSize
|
||||
}
|
||||
}
|
||||
|
||||
print() {
|
||||
this.traverse((entry, depth) => console.log("\t".repeat(depth), entry.entryName));
|
||||
}
|
||||
|
||||
traverse(action: (entry: DirectoryEntry, depth: number) => void) {
|
||||
this.traverseFromRoot(0, 0, action);
|
||||
}
|
||||
|
||||
private traverseFromRoot(root: number, depth: number, action: (entry: DirectoryEntry, depth: number) => void) {
|
||||
if (root < 0 || root >= this.entries.length) return;
|
||||
|
||||
const entry = this.entries[root];
|
||||
if (!entry) return;
|
||||
|
||||
this.traverseFromRoot(entry.leftSiblingId, depth, action);
|
||||
this.traverseFromRoot(entry.rightSiblingId, depth, action);
|
||||
|
||||
action(entry, depth);
|
||||
this.traverseFromRoot(entry.childId, depth + 1, action);
|
||||
}
|
||||
|
||||
private compareName(name1: string, name2: string) {
|
||||
if (name1.length < name2.length) return -1;
|
||||
if (name1.length > name2.length) return 1;
|
||||
return name1.toUpperCase().localeCompare(name2.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export enum ColorFlag {
|
||||
Red = 0x00,
|
||||
Black = 0x01
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export enum ObjectType {
|
||||
Unknown = 0x00,
|
||||
Storage = 0x01,
|
||||
Stream = 0x02,
|
||||
RootStorage = 0x05
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
export interface DirectoryEntry {
|
||||
/**
|
||||
* Directory Entry Name (64 bytes): This field MUST contain a Unicode string for the storage or
|
||||
* stream name encoded in UTF-16. The name MUST be terminated with a UTF-16 terminating null
|
||||
* character. Thus, storage and stream names are limited to 32 UTF-16 code points, including the
|
||||
* terminating null character. When locating an object in the compound file except for the root
|
||||
* storage, the directory entry name is compared by using a special case-insensitive uppercase
|
||||
* mapping, described in Red-Black Tree. The following characters are illegal and MUST NOT be part
|
||||
* of the name: '/', '\', ':', '!'.
|
||||
*/
|
||||
entryName: string,
|
||||
|
||||
/**
|
||||
* Directory Entry Name Length (2 bytes): This field MUST match the length of the Directory Entry
|
||||
* Name Unicode string in bytes. The length MUST be a multiple of 2 and include the terminating null
|
||||
* character in the count. This length MUST NOT exceed 64, the maximum size of the Directory Entry
|
||||
* Name field.
|
||||
*/
|
||||
entryNameLength: number,
|
||||
|
||||
/**
|
||||
* Object Type (1 byte): This field MUST be 0x00, 0x01, 0x02, or 0x05, depending on the actual type of object. All other values are not valid.
|
||||
*/
|
||||
objectType: ObjectType,
|
||||
|
||||
/**
|
||||
* Color Flag (1 byte): This field MUST be 0x00 (red) or 0x01 (black). All other values are not valid
|
||||
*/
|
||||
colorFlag: ColorFlag,
|
||||
|
||||
/**
|
||||
* Left Sibling ID (4 bytes): This field contains the stream ID of the left sibling. If there is no left sibling, the field MUST be set to NOSTREAM (0xFFFFFFFF).
|
||||
* ---------------------------------------------------------------------------------
|
||||
* Value | Meaning
|
||||
* -------------------------------|-------------------------------------------------
|
||||
* REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry.
|
||||
* MAXREGSID 0xFFFFFFFA | Maximum regular stream ID.
|
||||
* NOSTREAM 0xFFFFFFFF | If there is no left sibling.
|
||||
*/
|
||||
leftSiblingId: number,
|
||||
|
||||
/**
|
||||
* Right Sibling ID (4 bytes): This field contains the stream ID of the right sibling. If there is no right sibling, the field MUST be set to NOSTREAM (0xFFFFFFFF).
|
||||
* ---------------------------------------------------------------------------------
|
||||
* Value | Meaning
|
||||
* -------------------------------|-------------------------------------------------
|
||||
* REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry.
|
||||
* MAXREGSID 0xFFFFFFFA | Maximum regular stream ID.
|
||||
* NOSTREAM 0xFFFFFFFF | If there is no right sibling.
|
||||
*/
|
||||
rightSiblingId: number,
|
||||
|
||||
/**
|
||||
* Child ID (4 bytes): This field contains the stream ID of a child object. If there is no child object, including all entries for stream objects, the field MUST be set to NOSTREAM (0xFFFFFFFF).
|
||||
* ---------------------------------------------------------------------------------
|
||||
* Value | Meaning
|
||||
* -------------------------------|-------------------------------------------------
|
||||
* REGSID 0x00000000 — 0xFFFFFFF9 | Regular stream ID to identify the directory entry.
|
||||
* MAXREGSID 0xFFFFFFFA | Maximum regular stream ID.
|
||||
* NOSTREAM 0xFFFFFFFF | If there is no child object.
|
||||
*/
|
||||
childId: number,
|
||||
|
||||
/**
|
||||
* CLSID (16 bytes): This field contains an object class GUID, if this entry is for a storage object or
|
||||
* root storage object. For a stream object, this field MUST be set to all zeroes. A value containing all
|
||||
* zeroes in a storage or root storage directory entry is valid, and indicates that no object class is
|
||||
* associated with the storage. If an implementation of the file format enables applications to create
|
||||
* storage objects without explicitly setting an object class GUID, it MUST write all zeroes by default.
|
||||
* If this value is not all zeroes, the object class GUID can be used as a parameter to start
|
||||
* applications.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------
|
||||
* Value | Meaning
|
||||
* -----------------------------------|-----------------------------------------------
|
||||
* 0x00000000000000000000000000000000 | No object class is associated with the storage.
|
||||
*/
|
||||
clsid: string,
|
||||
|
||||
/**
|
||||
* State Bits (4 bytes): This field contains the user-defined flags if this entry is for a storage object or
|
||||
* root storage object. For a stream object, this field SHOULD be set to all zeroes because many
|
||||
* implementations provide no way for applications to retrieve state bits from a stream object. If an
|
||||
* implementation of the file format enables applications to create storage objects without explicitly
|
||||
* setting state bits, it MUST write all zeroes by default.
|
||||
* --------------------------------------------------------------------------------
|
||||
* Value | Meaning
|
||||
* ------------|-------------------------------------------------------------------
|
||||
* 0x00000000 | Default value when no state bits are explicitly set on the object.
|
||||
*/
|
||||
stateBits: number,
|
||||
|
||||
/**
|
||||
* Creation Time (8 bytes): This field contains the creation time for a storage object, or all zeroes to
|
||||
* indicate that the creation time of the storage object was not recorded. The Windows FILETIME
|
||||
* structure is used to represent this field in UTC. For a stream object, this field MUST be all zeroes.
|
||||
* For a root storage object, this field MUST be all zeroes, and the creation time is retrieved or set on
|
||||
* the compound file itself.
|
||||
* -------------------------------------------------------------------
|
||||
* Value | Meaning
|
||||
* --------------------|----------------------------------------------
|
||||
* 0x0000000000000000 | No creation time was recorded for the object.
|
||||
*/
|
||||
creationTime: bigint,
|
||||
|
||||
/**
|
||||
* Modified Time (8 bytes): This field contains the modification time for a storage object, or all
|
||||
* zeroes to indicate that the modified time of the storage object was not recorded. The Windows
|
||||
* FILETIME structure is used to represent this field in UTC. For a stream object, this field MUST be
|
||||
* all zeroes. For a root storage object, this field MAY<2> be set to all zeroes, and the modified time
|
||||
* is retrieved or set on the compound file itself.
|
||||
* -------------------------------------------------------------------
|
||||
* Value | Meaning
|
||||
* --------------------|----------------------------------------------
|
||||
* 0x0000000000000000 | No modified time was recorded for the object.
|
||||
*/
|
||||
modifiedTime: bigint,
|
||||
|
||||
/**
|
||||
* Starting Sector Location (4 bytes): This field contains the first sector location if this is a stream
|
||||
* object. For a root storage object, this field MUST contain the first sector of the mini stream, if the
|
||||
* mini stream exists. For a storage object, this field MUST be set to all zeroes.
|
||||
*/
|
||||
startingSectorLocation: number,
|
||||
|
||||
/**
|
||||
* Stream Size (8 bytes): This 64-bit integer field contains the size of the user-defined data if this is
|
||||
* a stream object. For a root storage object, this field contains the size of the mini stream. For a
|
||||
* storage object, this field MUST be set to all zeroes.
|
||||
*/
|
||||
streamSize: bigint
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { Header } from "./header";
|
||||
import { fatSectorSize, sectorOffset } from "./util";
|
||||
|
||||
/**
|
||||
* The FAT is an array of sector numbers that represent the allocation of space within the file, grouped
|
||||
* into FAT sectors. Each stream is represented in the FAT by a sector chain, in much the same fashion
|
||||
* as a FAT file system.
|
||||
*/
|
||||
export function getFat(view: DataView, header: Header, difat: number[]): number[] {
|
||||
const sectorSize = fatSectorSize(header);
|
||||
|
||||
const fat: number[] = [];
|
||||
for (let i = 0; i < difat.length; i++) {
|
||||
let offset = sectorOffset(difat[i], header.sectorSize);
|
||||
|
||||
for (let j = 0; j < sectorSize; j++) {
|
||||
const nextSector = view.getUint32(offset, true);
|
||||
fat.push(nextSector);
|
||||
offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
return fat;
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
export interface Header {
|
||||
// Header Signature (8 bytes): Identification signature for the compound file structure, and MUST be set to the value 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1.
|
||||
signature: number[],
|
||||
|
||||
// Minor Version (2 bytes): Version number for nonbreaking changes. This field SHOULD be set to 0x003E if the major version field is either 0x0003 or 0x0004.
|
||||
minorVersion: number,
|
||||
|
||||
// Major Version (2 bytes): Version number for breaking changes. This field MUST be set to either 0x0003 (version 3) or 0x0004 (version 4).
|
||||
majorVersion: number,
|
||||
|
||||
// Byte Order (2 bytes): This field MUST be set to 0xFFFE. This field is a byte order mark for all integer fields, specifying little-endian byte order.
|
||||
byteOrder: number,
|
||||
|
||||
// Sector Shift (2 bytes): This field MUST be set to 0x0009, or 0x000c, depending on the Major Version field.
|
||||
// Sector Size = 2 ^ SectorShift. e.g. 2 ^ 9 = 512, 2 ^ 12 = 4096
|
||||
sectorSize: number,
|
||||
|
||||
// Mini Sector Shift (2 bytes): This field MUST be set to 0x0006. This field specifies the sector size of the Mini Stream as a power of 2. The sector size of the Mini Stream MUST be 64 bytes.
|
||||
// Mini Sector Size = 2 ^ Mini Sector Shift. e.g. 2 ^ 6 = 64
|
||||
miniSectorSize: number,
|
||||
|
||||
// Number of Directory Sectors (4 bytes): This integer field contains the count of the number of directory sectors in the compound file.
|
||||
numberOfDirectorySectors: number,
|
||||
|
||||
// Number of FAT Sectors (4 bytes): This integer field contains the count of the number of FAT sectors in the compound file.
|
||||
numberOfFatSectors: number,
|
||||
|
||||
// First Directory Sector Location (4 bytes): This integer field contains the starting sector number for the directory stream.
|
||||
firstDirSectorLocation: number,
|
||||
|
||||
// Transaction Signature Number (4 bytes): This integer field MAY contain a sequence number that is incremented every time the compound file is saved by an implementation that supports file transactions. This is the field that MUST be set to all zeroes if file transactions are not implemented.
|
||||
transactionSignatureNumber: number,
|
||||
|
||||
// Mini Stream Cutoff Size (4 bytes): This integer field MUST be set to 0x00001000. This field specifies the maximum size of a user-defined data stream that is allocated from the mini FAT and mini stream, and that cutoff is 4,096 bytes. Any user-defined data stream that is greater than or equal to this cutoff size must be allocated as normal sectors from the FAT.
|
||||
miniStreamCutOffSize: number,
|
||||
|
||||
// First Mini FAT Sector Location (4 bytes): This integer field contains the starting sector number for the mini FAT.
|
||||
firstMiniFatSectorLocation: number,
|
||||
|
||||
// Number of Mini FAT Sectors (4 bytes): This integer field contains the count of the number of mini FAT sectors in the compound file.
|
||||
numberOfMiniFatSectors: number,
|
||||
|
||||
// First DIFAT Sector Location (4 bytes): This integer field contains the starting sector number for the DIFAT.
|
||||
firstDifatSectorLocation: number,
|
||||
|
||||
// Number of DIFAT Sectors (4 bytes): This integer field contains the count of the number of DIFAT sectors in the compound file.
|
||||
numberOfDifatSectors: number,
|
||||
|
||||
// DIFAT (436 bytes): This array of 32-bit integer fields contains the first 109 FAT sector locations of the compound file. For version 4 compound files, the header size (512 bytes) is less than the sector size (4,096 bytes), so the remaining part of the header (3,584 bytes) MUST be filled with all zeroes.
|
||||
// The DIFAT array is used to represent storage of the FAT sectors.
|
||||
difat: number[]
|
||||
}
|
||||
|
||||
export function getHeader(view: DataView): Header {
|
||||
const signature = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
for (var offset = 0; offset < 8; offset++) {
|
||||
if (view.getUint8(offset) != signature[offset]) {
|
||||
throw new Error("Signature mismatch!");
|
||||
}
|
||||
}
|
||||
|
||||
// Header CLSID (16 bytes): Reserved and unused class ID that MUST be set to all zeroes
|
||||
offset += 16;
|
||||
|
||||
const minorVersion = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
|
||||
const majorVersion = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
|
||||
const byteOrder = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
|
||||
const sectorSize = Math.pow(2, view.getUint16(offset, true));
|
||||
offset += 2;
|
||||
|
||||
const miniSectorSize = Math.pow(2, view.getUint16(offset, true));
|
||||
offset += 2;
|
||||
|
||||
// Reserved (6 bytes): This field MUST be set to all zeroes.
|
||||
offset += 6;
|
||||
|
||||
const numberOfDirectorySectors = view.getUint32(offset, true)
|
||||
offset += 4;
|
||||
|
||||
const numberOfFatSectors = view.getUint32(offset, true)
|
||||
offset += 4;
|
||||
|
||||
const firstDirSectorLocation = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const transactionSignatureNumber = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const miniStreamCutOffSize = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const firstMiniFatSectorLocation = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const numberOfMiniFatSectors = view.getUint32(offset, true)
|
||||
offset += 4;
|
||||
|
||||
const firstDifatSectorLocation = view.getUint32(offset, true)
|
||||
offset += 4;
|
||||
|
||||
const numberOfDifatSectors = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const difat: number[] = [];
|
||||
for (let i = 0; i < 436; i += 4) {
|
||||
const fat = view.getUint32(offset, true);
|
||||
if (fat == 0xFFFFFFFF) break;
|
||||
|
||||
difat.push(fat);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
return {
|
||||
signature, byteOrder, sectorSize, miniSectorSize, minorVersion, majorVersion, numberOfDirectorySectors,
|
||||
numberOfFatSectors, firstDirSectorLocation, transactionSignatureNumber,
|
||||
miniStreamCutOffSize, firstMiniFatSectorLocation, numberOfMiniFatSectors,
|
||||
firstDifatSectorLocation, numberOfDifatSectors, difat
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Header } from "./header";
|
||||
import { fatSectorSize, sectorOffset } from "./util";
|
||||
|
||||
/**
|
||||
* The mini FAT is used to allocate space in the mini stream. The mini stream is divided into smaller,
|
||||
* equal-length sectors, and the sector size that is used for the mini stream is specified from the
|
||||
* Compound File Header (64 bytes).
|
||||
*/
|
||||
export function getMiniFat(view: DataView, header: Header, fat: number[]): number[] {
|
||||
const sectorSize = fatSectorSize(header);
|
||||
|
||||
const miniFat = [];
|
||||
|
||||
let sector = header.firstMiniFatSectorLocation;
|
||||
while (sector < 0xFFFFFFFE) {
|
||||
let offset = sectorOffset(sector, header.sectorSize);
|
||||
for (let i = 0; i < sectorSize; i++) {
|
||||
miniFat.push(view.getUint32(offset, true));
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
sector = fat[sector];
|
||||
}
|
||||
|
||||
return miniFat;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { Header } from "./header";
|
||||
|
||||
/**
|
||||
* Based on a non-stream sector number returns the byte offset in the Buffer.
|
||||
*/
|
||||
export function sectorOffset(sector: number, sectorSize: number): number {
|
||||
return (sector + 1) * sectorSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on a stream sector number returns the byte offset in the Buffer.
|
||||
*/
|
||||
export function streamSectorOffset(sector: number, header: Header, streamSize: bigint, miniStreamLocations: number[]): number {
|
||||
let offset = sectorOffset(sector, header.sectorSize);
|
||||
|
||||
// Calculates the offset in ministream if streamSize is smaller than miniStreamCutOffSize
|
||||
if (streamSize < header.miniStreamCutOffSize) {
|
||||
offset = sector * header.miniSectorSize;
|
||||
const miniStreamSector = Math.floor(offset / header.sectorSize);
|
||||
const miniStreamOffset = offset % header.sectorSize;
|
||||
offset = sectorOffset(miniStreamLocations[miniStreamSector], header.sectorSize) + miniStreamOffset ;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
export function fatSectorSize(header: Header) {
|
||||
// If Header Major Version is 3, there MUST be 128 fields specified to fill a 512-byte sector.
|
||||
// If Header Major Version is 4, there MUST be 1,024 fields specified to fill a 4,096-byte sector.
|
||||
return header.majorVersion == 3 ? 128 : 1024;
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { CompoundFile } from "./compound-file/compound-file";
|
||||
import { TEXT_DECODER } from "./compound-file/constants/text-decoder";
|
||||
import type { DirectoryEntry } from "./compound-file/directory/types/directory-entry";
|
||||
import { ATTACH_PROPERTIES, PropertySource, RECIP_PROPERTIES, ROOT_PROPERTIES, type Property } from "./streams/property/properties";
|
||||
import { getPropertyStreamEntry } from "./streams/property/property-stream";
|
||||
import { PtypBinary, PtypObject, PtypString, PtypTime, type PropertyType } from "./streams/property/property-types";
|
||||
import type { PropertyStreamEntry } from "./streams/property/types/property-stream-entry";
|
||||
import type { Attachment, Message, MessageContent, Recipient } from "./types/message";
|
||||
|
||||
export function parse(view: DataView): Message {
|
||||
const file = CompoundFile.create(view);
|
||||
const dir = file.directory.entries[0];
|
||||
|
||||
return parseDir(file, dir);
|
||||
}
|
||||
|
||||
export function parseDir(file: CompoundFile, dir: DirectoryEntry): Message {
|
||||
const pStreamEntry = getPropertyStreamEntry(file, dir)!;
|
||||
|
||||
return {
|
||||
file: file,
|
||||
content: getContent(file, dir, pStreamEntry),
|
||||
attachments: getAttachments(file, dir),
|
||||
recipients: getRecipients(file, dir),
|
||||
};
|
||||
}
|
||||
|
||||
function getContent(file: CompoundFile, dir: DirectoryEntry, pStreamEntry: PropertyStreamEntry): MessageContent {
|
||||
return getValue(file, ROOT_PROPERTIES, dir, pStreamEntry);
|
||||
}
|
||||
|
||||
function getRecipients(file: CompoundFile, dir: DirectoryEntry): Recipient[] {
|
||||
return getValues(file, dir, RECIP_PROPERTIES, "recip");
|
||||
}
|
||||
|
||||
function getAttachments(file: CompoundFile, dir: DirectoryEntry): Attachment[] {
|
||||
return getValues(file, dir, ATTACH_PROPERTIES, "attach");
|
||||
}
|
||||
|
||||
function getValues<T>(file: CompoundFile, dir: DirectoryEntry, properties: Property[], prefix: string): T[] {
|
||||
const list: T[] = [];
|
||||
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
const directory = file.directory.get(`__${prefix}_version1.0_#${i.toString(16).padStart(8, "0")}`, dir.childId, false);
|
||||
if (!directory) break;
|
||||
|
||||
const pStreamEntry = getPropertyStreamEntry(file, directory)!;
|
||||
list.push(getValue(file, properties, directory, pStreamEntry));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function getValue<T>(file: CompoundFile, properties: Property[], dir: DirectoryEntry, entry: PropertyStreamEntry): T {
|
||||
return properties.reduce((acc, p) => {
|
||||
if (p.source == PropertySource.Stream) {
|
||||
const streamName = `__substg1.0_${p.id.padStart(4, "0")}${p.type.id.toString(16).padStart(4, "0")}`;
|
||||
const entry = file.directory.get(streamName, dir.childId, false);
|
||||
if (!entry) return acc;
|
||||
acc[p.name as keyof T] = getValueFromStream(file, entry, p.type) as T[keyof T];
|
||||
} else {
|
||||
const value = getValueFromProperty(entry, p);
|
||||
if (!value) return acc;
|
||||
acc[p.name as keyof T] = value as T[keyof T];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as T);
|
||||
}
|
||||
|
||||
function getValueFromProperty(entry: PropertyStreamEntry, property: Property) {
|
||||
const value = entry.data.get(property.id.toLowerCase())?.valueOrSize;
|
||||
if (!value) return "";
|
||||
|
||||
switch (property.type) {
|
||||
case PtypTime: {
|
||||
// Subtracting the number of seconds between January 1, 1601 and January 1, 1970.
|
||||
return new Date(Number(value as bigint / 10000n) - 1.16444736e13);
|
||||
}
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
function getValueFromStream(file: CompoundFile, entry: DirectoryEntry, type: PropertyType) {
|
||||
switch (type) {
|
||||
case PtypString: {
|
||||
let value = "";
|
||||
file.readStream(entry, (offset, bytes) => {
|
||||
value += TEXT_DECODER.decode(new DataView(file.view.buffer, offset, bytes));
|
||||
});
|
||||
|
||||
return value;
|
||||
};
|
||||
case PtypBinary: {
|
||||
const chunks = new Uint8Array(Number(entry.streamSize));
|
||||
let pos = 0;
|
||||
file.readStream(entry, (offset, bytes) => {
|
||||
const chunk = file.view.buffer.slice(offset, offset + bytes);
|
||||
chunks.set(new Uint8Array(chunk), pos);
|
||||
pos += bytes;
|
||||
});
|
||||
|
||||
return new DataView(chunks.buffer);
|
||||
};
|
||||
case PtypObject: {
|
||||
return entry;
|
||||
}
|
||||
default: return null;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@molotochok/msg-viewer",
|
||||
"version": "1.0.3",
|
||||
"author": "Markiian Karpa",
|
||||
"main": "msg-parser.ts",
|
||||
"description": "This tool allows you to read Outlook .msg file.",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
const TABLE = [0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d];
|
||||
|
||||
/**
|
||||
* Cyclic redundancy check which is used to check if the RTF file is corrupted.
|
||||
* It must be equal to the value taken from the header.
|
||||
*/
|
||||
export function crc(view: DataView, headerSize: number) {
|
||||
let tablePosition = 0, intermidiateValue = 0, crcValue = 0;
|
||||
|
||||
for (let i = headerSize; i < view.byteLength; i++) {
|
||||
tablePosition = (crcValue ^ view.getUint8(i)) & 0xFF;
|
||||
intermidiateValue = crcValue >>> 8;
|
||||
// >>> 0 is needed to avoid a negative number.
|
||||
// In this way JS will treat the number as unsigned.
|
||||
crcValue = (TABLE[tablePosition] ^ intermidiateValue) >>> 0;
|
||||
}
|
||||
|
||||
return crcValue;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
const STR = "{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx";
|
||||
|
||||
let dict: Uint8Array | null = null;
|
||||
|
||||
export function getDictionary(): Uint8Array {
|
||||
if (!dict) {
|
||||
dict = new Uint8Array(4096);
|
||||
for (let i = 0; i < STR.length; i++) {
|
||||
dict[i] = STR.charCodeAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
export function getHeader(view: DataView): Header {
|
||||
let offset = 0;
|
||||
|
||||
const compSize = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const rawSize = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const compType = view.getUint32(offset, true) === 0x75465A4C ? CompType.Compressed : CompType.Uncompressed;
|
||||
offset += 4;
|
||||
|
||||
const crc = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
return { compSize, rawSize, compType, crc, headerSize: offset };
|
||||
}
|
||||
|
||||
export interface Header {
|
||||
/**
|
||||
* Writers MUST set the COMPSIZE field to the length of the compressed data
|
||||
* (the CONTENTS field) in bytes plus 12 (the count of the remaining bytes from the header).
|
||||
*/
|
||||
compSize: number;
|
||||
|
||||
/**
|
||||
* The size in bytes of the uncompressed content.
|
||||
*/
|
||||
rawSize: number;
|
||||
|
||||
/**
|
||||
* The type of compression.
|
||||
*/
|
||||
compType: CompType;
|
||||
|
||||
/**
|
||||
* Cyclic redundancy check. Used to verify that the file is not broken.
|
||||
* If the COMPTYPE field is set to COMPRESSED, then the CRC field is computed from the CONTENTS field.
|
||||
* If the COMPTYPE field is set to UNCOMPRESSED, then the CRC field MUST be set to %x00.00.00.00.
|
||||
*/
|
||||
crc: number;
|
||||
|
||||
/**
|
||||
* The size in bytes of the header.
|
||||
*/
|
||||
headerSize: number;
|
||||
}
|
||||
|
||||
export const enum CompType { Compressed, Uncompressed }
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { crc } from "./decompressor/crc";
|
||||
import { getDictionary } from "./decompressor/dictionary";
|
||||
import { CompType, getHeader, type Header } from "./decompressor/header";
|
||||
|
||||
export function decompressRTF(view: DataView): string {
|
||||
const header = getHeader(view);
|
||||
return getContent(view, header);
|
||||
}
|
||||
|
||||
function getContent(view: DataView, header: Header): string {
|
||||
if (header.compType == CompType.Uncompressed) {
|
||||
const buffer = new Uint8Array(view.buffer.slice(header.headerSize, header.headerSize + header.rawSize));
|
||||
return String.fromCharCode(...buffer);
|
||||
}
|
||||
|
||||
const currentCRC = crc(view, header.headerSize);
|
||||
if (currentCRC !== header.crc) {
|
||||
throw new Error(`CRC mismatch! Expected ${header.crc}, got ${currentCRC}.`);
|
||||
}
|
||||
|
||||
const output = [];
|
||||
const dictionary = getDictionary();
|
||||
|
||||
let offset = header.headerSize;
|
||||
let writeOffset = 207;
|
||||
let readOffset = 0;
|
||||
let canRun = true;
|
||||
while (offset <= header.compSize + 4 && canRun) {
|
||||
const control = view.getUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const bit = (control >>> i) & 1;
|
||||
|
||||
if (bit == 0) {
|
||||
const literal = view.getUint8(offset);
|
||||
offset += 1;
|
||||
|
||||
dictionary[writeOffset] = literal;
|
||||
writeOffset = (writeOffset + 1) % dictionary.length;
|
||||
|
||||
output.push(literal);
|
||||
} else {
|
||||
const ref = view.getUint16(offset);
|
||||
const refOffset = ref >>> 4;
|
||||
offset += 2;
|
||||
|
||||
if (refOffset == writeOffset) {
|
||||
canRun = false;
|
||||
break;
|
||||
}
|
||||
|
||||
readOffset = refOffset;
|
||||
const refLength = 2 + (ref & 0x0f);
|
||||
for (let j = 0; j < refLength; j++) {
|
||||
const byte = dictionary[readOffset];
|
||||
readOffset = (readOffset + 1) % dictionary.length;
|
||||
|
||||
dictionary[writeOffset] = byte;
|
||||
writeOffset = (writeOffset + 1) % dictionary.length;
|
||||
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String.fromCharCode(...output);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const NAME_ID_FOLDER_NAME = "__nameid_version1.0";
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { CompoundFile } from "../../compound-file/compound-file";
|
||||
import { NAME_ID_FOLDER_NAME } from "../constants/folders";
|
||||
import type { EntryStreamData } from "./types/entry-stream-entry";
|
||||
|
||||
const DATA_SIZE = 8;
|
||||
const STREAM_NAME = "__substg1.0_00030102";
|
||||
|
||||
export function getEntryStreamData(file: CompoundFile): EntryStreamData[] | null {
|
||||
const folder = file.directory.get(NAME_ID_FOLDER_NAME, file.directory.entries[0].childId, false);
|
||||
if (!folder) return null;
|
||||
|
||||
const entry = file.directory.get(STREAM_NAME, folder.childId, false);
|
||||
if (!entry) return null;
|
||||
|
||||
const list: EntryStreamData[] = [];
|
||||
file.readStream(entry,
|
||||
(offset, bytes) => {
|
||||
const data = getData(file.view, offset);
|
||||
list.push(data);
|
||||
},
|
||||
DATA_SIZE,
|
||||
);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function getData(view: DataView, offset: number): EntryStreamData {
|
||||
const nameIdOrStringOffset = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const guidWithKind = view.getUint16(offset, true);
|
||||
const propertyKind = guidWithKind & 1;
|
||||
const guidIndex = guidWithKind >> 1;
|
||||
offset += 2;
|
||||
|
||||
const propertyIndex = view.getUint16(offset, true)
|
||||
offset += 2;
|
||||
|
||||
return {
|
||||
nameIdOrStringOffset,
|
||||
guidIndex,
|
||||
propertyIndex,
|
||||
propertyKind
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
export interface EntryStreamData {
|
||||
/**
|
||||
* Name Identifier/String Offset (4 bytes): If this property is a numerical named property (as specified by the Property Kind subfield of the Index and Kind Information field), this value is the LID part of the PropertyName structure, as specified in [MS-OXCDATA] section 2.6.1. If this property is a string named property, this value is the offset in bytes into the strings stream where the value of the Name field of the PropertyName structure is located.
|
||||
*/
|
||||
nameIdOrStringOffset: number,
|
||||
|
||||
/**
|
||||
* Property Index (2 bytes): Sequentially increasing, zero-based index. This MUST be 0 for the first named property, 1 for the second, and so on.
|
||||
*/
|
||||
propertyIndex: number,
|
||||
|
||||
/**
|
||||
* GUID Index (15 bits): Index into the GUID stream. The possible values are shown in the following table.
|
||||
*/
|
||||
guidIndex: number,
|
||||
|
||||
/**
|
||||
* Property Kind (1 bit): Bit indicating the type of the property; zero (0) if numerical named property and 1 if string named property.
|
||||
*/
|
||||
propertyKind: PropertyKind
|
||||
}
|
||||
|
||||
export enum PropertyKind {
|
||||
NUMERICAL = 0,
|
||||
STRING = 1
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { PtypBinary, PtypObject, PtypString, PtypTime, type PropertyType } from "./property-types";
|
||||
|
||||
export const enum PropertySource {
|
||||
Stream, // Property can be found in a dedicated stream
|
||||
Property // Property is located in property stream
|
||||
}
|
||||
|
||||
export const ROOT_PROPERTIES: Property[] = [
|
||||
{ id: "0E06", name:"date", type: PtypTime, source: PropertySource.Property },
|
||||
{ id: "0037", name:"subject", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "0c1a", name:"senderName", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "5d02", name:"senderEmail", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "1000", name:"body", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "1013", name:"bodyHTML", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "1009", name:"bodyRTF", type: PtypBinary, source: PropertySource.Stream },
|
||||
{ id: "007d", name:"headers", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "0E04", name:"toRecipients", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "0E03", name:"ccRecipients", type: PtypString, source: PropertySource.Stream },
|
||||
];
|
||||
|
||||
export const ATTACH_PROPERTIES: Property[]= [
|
||||
{ id: "3703", name:"extension", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "3707", name:"fileName", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "370e", name:"mimeType", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "3A0C", name:"language", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "3001", name:"displayName", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "3701", name:"content", type: PtypBinary, source: PropertySource.Stream },
|
||||
{ id: "3701", name:"embeddedMsgObj", type: PtypObject, source: PropertySource.Stream },
|
||||
];
|
||||
|
||||
export const RECIP_PROPERTIES: Property[] = [
|
||||
{ id: "3001", name:"name", type: PtypString, source: PropertySource.Stream },
|
||||
{ id: "39fe", name:"email", type: PtypString, source: PropertySource.Stream },
|
||||
];
|
||||
|
||||
export interface Property {
|
||||
id: string,
|
||||
name: string,
|
||||
type: PropertyType,
|
||||
source: PropertySource,
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import type { CompoundFile } from "../../compound-file/compound-file";
|
||||
import type { DirectoryEntry } from "../../compound-file/directory/types/directory-entry";
|
||||
import { PROPERTY_TYPES } from "./property-types";
|
||||
import type { PropertyHeader } from "./types/property-header";
|
||||
import type { PropertyData } from "./types/property-data";
|
||||
import type { PropertyStreamEntry } from "./types/property-stream-entry";
|
||||
|
||||
const STREAM_NAME = "__properties_version1.0";
|
||||
const DATA_SIZE = 16;
|
||||
|
||||
export function getPropertyStreamEntry(file: CompoundFile, folder: DirectoryEntry): PropertyStreamEntry | null {
|
||||
const entry = file.directory.get(STREAM_NAME, folder.childId, false);
|
||||
if (!entry) return null;
|
||||
|
||||
let header;
|
||||
const data = new Map();
|
||||
file.readStream(entry,
|
||||
(offset) => {
|
||||
const property = getProperty(file.view, offset);
|
||||
data.set(property.propertyId.toString(16).toLowerCase().padStart(4, "0"), property);
|
||||
},
|
||||
DATA_SIZE,
|
||||
(offset) => {
|
||||
header = getHeader(file.view, offset, folder.entryName);
|
||||
return header.size;
|
||||
}
|
||||
);
|
||||
|
||||
return { header: header!, data: data };
|
||||
}
|
||||
|
||||
function getProperty(view: DataView, offset: number): PropertyData {
|
||||
const propertyTag = view.getUint32(offset, true);
|
||||
const propertyType = PROPERTY_TYPES[0xFFFF & propertyTag];
|
||||
const propertyId = propertyTag >>> 16;
|
||||
|
||||
offset += 4;
|
||||
|
||||
const flags = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const valueOrSize = (!propertyType?.size || propertyType?.multi)
|
||||
? view.getUint32(offset, true)
|
||||
: (propertyType.size == 1)
|
||||
? view.getUint8(offset)
|
||||
: propertyType.size == 2
|
||||
? view.getUint16(offset, true)
|
||||
: propertyType.size == 4
|
||||
? view.getUint32(offset, true)
|
||||
: view.getBigUint64(offset, true);
|
||||
|
||||
return {
|
||||
propertyType,
|
||||
propertyId,
|
||||
flags,
|
||||
valueOrSize
|
||||
};
|
||||
}
|
||||
|
||||
function getHeader(view: DataView, offset: number, folderName: string): PropertyHeader {
|
||||
if (["__attach", "__recip"].some(v => folderName.startsWith(v))) return { size: 8 };
|
||||
|
||||
const initialOffset = offset;
|
||||
|
||||
// Reserved (8 bytes): This field MUST be set to zero when writing a .msg file and MUST be ignored when reading a .msg file.
|
||||
offset += 8;
|
||||
|
||||
const nextRecipientId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const nextAttachmentId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const recipientCount = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const attachmentCount = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
if (folderName.startsWith("Root")) {
|
||||
// Reserved (8 bytes): This field MUST be set to 0 when writing a .msg file and MUST be ignored when reading a .msg file.
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
|
||||
return { size: offset - initialOffset, nextRecipientId, nextAttachmentId, recipientCount, attachmentCount };
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**** Fixed length ****/
|
||||
export const PtypInteger16: PropertyType = { id: 0x0002, name: "PtypInteger16", size: 2, multi: false }
|
||||
export const PtypInteger32: PropertyType = { id: 0x0003, name: "PtypInteger32", size: 4, multi: false }
|
||||
export const PtypFloating32: PropertyType = { id: 0x0004, name: "PtypFloating32", size: 4, multi: false }
|
||||
export const PtypFloating64: PropertyType = { id: 0x0005, name: "PtypFloating64", size: 8, multi: false }
|
||||
export const PtypBoolean: PropertyType = { id: 0x000B, name: "PtypBoolean", size: 1, multi: false }
|
||||
export const PtypCurrency: PropertyType = { id: 0x0006, name: "PtypCurrency", size: 8, multi: false }
|
||||
export const PtypFloatingTime: PropertyType = { id: 0x0007, name: "PtypFloatingTime", size: 8, multi: false }
|
||||
export const PtypTime: PropertyType = { id: 0x0040, name: "PtypTime", size: 8, multi: false }
|
||||
export const PtypInteger64: PropertyType = { id: 0x0014, name: "PtypInteger64", size: 8, multi: false }
|
||||
export const PtypErrorCode: PropertyType = { id: 0x000A, name: "PtypErrorCode", size: 4, multi: false }
|
||||
|
||||
/**** Variable length ****/
|
||||
// Variable size; a string of Unicode characters in UTF-16LE format encoding with terminating null character (0x0000).
|
||||
export const PtypString: PropertyType = { id: 0x001F, name: "PtypString", multi: false }
|
||||
// Variable size; a string of multibyte characters in externally specified encoding with terminating null character (single 0 byte).
|
||||
export const PtypString8: PropertyType = { id: 0x001E, name: "PtypString8", multi: false }
|
||||
// Variable size; a COUNT field followed by that many bytes.
|
||||
export const PtypBinary: PropertyType = { id: 0x0102, name: "PtypBinary", multi: false }
|
||||
// 16 bytes; a GUID with Data1, Data2, and Data3 fields in little-endian format.
|
||||
export const PtypGuid: PropertyType = { id: 0x0048, name: "PtypGuid", size: 16, multi: false }
|
||||
// The property value is a Component Object Model (COM) object
|
||||
export const PtypObject: PropertyType = { id: 0x000D, name: "PtypObject", multi: false }
|
||||
|
||||
/**** Fixed length Multiple-Valued Properties ****/
|
||||
export const PtypMultipleInteger16: PropertyType = { id: 0x1002, name: "PtypMultipleInteger16", size: 2, multi: true }
|
||||
export const PtypMultipleInteger32: PropertyType = { id: 0x1003, name: "PtypMultipleInteger32", size: 4, multi: true }
|
||||
export const PtypMultipleFloating32: PropertyType = { id: 0x1004, name: "PtypMultipleFloating32", size: 4, multi: true }
|
||||
export const PtypMultipleFloating64: PropertyType = { id: 0x1005, name: "PtypMultipleFloating64", size: 8, multi: true }
|
||||
export const PtypMultipleCurrency: PropertyType = { id: 0x1006, name: "PtypMultipleCurrency", size: 8, multi: true }
|
||||
export const PtypMultipleFloatingTime: PropertyType = { id: 0x1007, name: "PtypMultipleFloatingTime", size: 8, multi: true }
|
||||
export const PtypMultipleTime: PropertyType = { id: 0x1040, name: "PtypMultipleTime", size: 8, multi: true }
|
||||
export const PtypMultipleGuid: PropertyType = { id: 0x1048, name: "PtypMultipleGuid", size: 16, multi: true }
|
||||
export const PtypMultipleInteger64: PropertyType = { id: 0x1014, name: "PtypMultipleInteger64", size: 8, multi: true }
|
||||
|
||||
/// Variable Length Multiple-Valued Properties
|
||||
export const PtypMultipleBinary: PropertyType = { id: 0x1102, name: "PtypMultipleBinary", multi: true }
|
||||
export const PtypMultipleString8: PropertyType = { id: 0x101E, name: "PtypMultipleString8", multi: true }
|
||||
export const PtypMultipleString: PropertyType = { id: 0x101F, name: "PtypMultipleString", multi: true }
|
||||
|
||||
export const PROPERTY_TYPES = {
|
||||
// Fixed length
|
||||
[PtypInteger16.id]: PtypInteger16,
|
||||
[PtypInteger32.id]: PtypInteger32,
|
||||
[PtypFloating32.id]: PtypFloating32,
|
||||
[PtypFloating64.id]: PtypFloating64,
|
||||
[PtypBoolean.id]: PtypBoolean,
|
||||
[PtypCurrency.id]: PtypCurrency,
|
||||
[PtypFloatingTime.id]: PtypFloatingTime,
|
||||
[PtypTime.id]: PtypTime,
|
||||
[PtypInteger64.id]: PtypInteger64,
|
||||
[PtypErrorCode.id]: PtypErrorCode,
|
||||
|
||||
// Variable length
|
||||
[PtypString.id]: PtypString,
|
||||
[PtypString8.id]: PtypString8,
|
||||
[PtypBinary.id]: PtypBinary,
|
||||
[PtypGuid.id]: PtypGuid,
|
||||
[PtypObject.id]: PtypObject,
|
||||
|
||||
// Fixed length multivalue
|
||||
[PtypMultipleInteger16.id]: PtypMultipleInteger16,
|
||||
[PtypMultipleInteger32.id]: PtypMultipleInteger32,
|
||||
[PtypMultipleFloating32.id]: PtypMultipleFloating32,
|
||||
[PtypMultipleFloating64.id]: PtypMultipleFloating64,
|
||||
[PtypMultipleCurrency.id]: PtypMultipleCurrency,
|
||||
[PtypMultipleFloatingTime.id]: PtypMultipleFloatingTime,
|
||||
[PtypMultipleTime.id]: PtypMultipleTime,
|
||||
[PtypMultipleGuid.id]: PtypMultipleGuid,
|
||||
[PtypMultipleInteger64.id]: PtypMultipleInteger64,
|
||||
|
||||
// Variable Length Multivalue
|
||||
[PtypMultipleBinary.id]: PtypMultipleBinary,
|
||||
[PtypMultipleString8.id]: PtypMultipleString8,
|
||||
[PtypMultipleString.id]: PtypMultipleString,
|
||||
};
|
||||
|
||||
export interface PropertyType {
|
||||
id: number,
|
||||
name: string,
|
||||
size?: number // in bytes,
|
||||
multi: boolean,
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { PropertyType } from "../property-types";
|
||||
|
||||
export interface PropertyData {
|
||||
/**
|
||||
* Property Type (2 bytes): The type of the property.
|
||||
*/
|
||||
propertyType: PropertyType,
|
||||
|
||||
/**
|
||||
* Property Id (2 bytes): The id of the property.
|
||||
*/
|
||||
propertyId: number,
|
||||
|
||||
/**
|
||||
* Flags (4 bytes): Flags giving context to the property.
|
||||
* Possible values for this field are given in the following table.
|
||||
* Any bitwise combination of the flags is valid.
|
||||
*
|
||||
* |--------------------|-------------|--------------------------------------------------------------------------------------------------|
|
||||
* | Flag name | Value | Description |
|
||||
* |--------------------|-------------|--------------------------------------------------------------------------------------------------|
|
||||
* | PROPATTR_MANDATORY | 0x00000001 | If this flag is set for a property, that property MUST NOT be deleted from the .msg file |
|
||||
* | | | (irrespective of which storage it is contained in) and implementations MUST return an error |
|
||||
* | | | if any attempt is made to do so. This flag is set in circumstances where the implementation |
|
||||
* | | | depends on that property always being present in the .msg file once it is written there. |
|
||||
* |--------------------|-------------|------------------------------------------------------------------------------------------------- |
|
||||
* | PROPATTR_READABLE | 0x00000002 | If this flag is not set on a property, that property MUST NOT be read from the .msg file and |
|
||||
* | | | implementations MUST return an error if any attempt is made to read it. This flag is set on all |
|
||||
* | | | properties unless there is an implementation-specific reason to prevent a property from being |
|
||||
* | | | read from the .msg file. |
|
||||
* |--------------------|-------------|--------------------------------------------------------------------------------------------------|
|
||||
* | PROPATTR_WRITABLE | 0x00000004 | If this flag is not set on a property, that property MUST NOT be modified or deleted and |
|
||||
* | | | implementations MUST return an error if any attempt is made to do so. This flag is set in |
|
||||
* | | | circumstances where the implementation depends on the properties being writable. |
|
||||
* |--------------------|-------------|--------------------------------------------------------------------------------------------------|
|
||||
*/
|
||||
flags: number,
|
||||
|
||||
/**
|
||||
* (8 bytes):
|
||||
* - If Fixed Length Property:
|
||||
* Data (variable): The value of the property. The size of this field depends upon the property type, which is specified in the Property Tag field, as specified in section 2.4.2.1. The size required for each property type is specified in [MS-OXCDATA] section 2.11.1.
|
||||
* Reserved (variable): This field MUST be ignored when reading a .msg file. The size of the Reserved field is the difference between 8 bytes and the size of the Data field; if the size of the Reserved field is greater than 0, this field MUST be set to 0 when writing a .msg file.
|
||||
* - If Variable Length Property:
|
||||
* Size (4 bytes): This value is interpreted based on the property type, which is specified in the Property Tag field. If the message contains an embedded message attachment or a storage attachment, this field MUST be set to 0xFFFFFFFF. Otherwise, the following table shows how this field is interpreted for each property type. The property types are specified in [MS-OXCDATA] section 2.11.1.
|
||||
* Reserved (4 bytes): This field MUST be ignored when reading a .msg file.
|
||||
*/
|
||||
valueOrSize: number | bigint,
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
export interface PropertyHeader {
|
||||
/**
|
||||
* Custom property used for definying the header size. Can differ based on the reserved bytes
|
||||
*/
|
||||
size: number
|
||||
|
||||
/**
|
||||
* Next Recipient ID (4 bytes): The ID to use for naming the next Recipient object storage if one is created inside the .msg file. The naming convention to be used is specified in section 2.2.1. If no Recipient object storages are contained in the .msg file, this field MUST be set to 0.
|
||||
*/
|
||||
nextRecipientId?: number,
|
||||
|
||||
/**
|
||||
* Next Attachment ID (4 bytes): The ID to use for naming the next Attachment object storage if one is created inside the .msg file. The naming convention to be used is specified in section 2.2.2. If no Attachment object storages are contained in the .msg file, this field MUST be set to 0.
|
||||
*/
|
||||
nextAttachmentId?: number,
|
||||
|
||||
/**
|
||||
* Recipient Count (4 bytes): The number of Recipient objects.
|
||||
*/
|
||||
recipientCount?: number,
|
||||
|
||||
/**
|
||||
* Attachment Count (4 bytes): The number of Attachment objects.
|
||||
*/
|
||||
attachmentCount?: number,
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { PropertyHeader } from "./property-header";
|
||||
import type { PropertyData } from "./property-data";
|
||||
|
||||
export interface PropertyStreamEntry {
|
||||
header: PropertyHeader,
|
||||
data: Map<string, PropertyData>
|
||||
}
|
||||
34
msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/types/message.d.ts
vendored
Normal file
34
msg_viewer/static/src/lib/msg-viewer-main/lib/scripts/msg/types/message.d.ts
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export interface Message {
|
||||
file: CompoundFile,
|
||||
content: MessageContent,
|
||||
attachments: Attachment[],
|
||||
recipients: Recipient[],
|
||||
}
|
||||
|
||||
export interface MessageContent {
|
||||
date: Date,
|
||||
subject: string,
|
||||
senderName: string,
|
||||
senderEmail: string,
|
||||
body: string,
|
||||
bodyHTML: string,
|
||||
bodyRTF: Buffer,
|
||||
headers: string,
|
||||
toRecipients: string,
|
||||
ccRecipients: string
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
extension: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
language: string,
|
||||
displayName: string,
|
||||
content: Buffer,
|
||||
embeddedMsgObj: DirectoryEntry
|
||||
}
|
||||
|
||||
export interface Recipient {
|
||||
name: string,
|
||||
email: string,
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
const SIZE = 1000;
|
||||
const UNITS = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
export function bytesWithUnits(bytes: number): string {
|
||||
let unit = 0;
|
||||
while(bytes >= SIZE){
|
||||
bytes = bytes / SIZE;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(bytes < 10 && unit > 0 ? 1 : 0)} ${UNITS[unit]}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
export function createFragmentFromTemplate<T>(html: string, obj: T): DocumentFragment {
|
||||
const element = document.createElement("div");
|
||||
element.innerHTML = fillHTMLTemplate(html, obj);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
while(element.children.length > 0) {
|
||||
fragment.appendChild(element.children[0]);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills an HTML template with object values in place of {{object.key}}
|
||||
*/
|
||||
export function fillHTMLTemplate<T>(html: string, obj: T): string {
|
||||
return html.replace(/{{(.*?)}}/g, (_m: string, key: string) => {
|
||||
return obj[key.trim() as keyof typeof obj] as string;
|
||||
})
|
||||
}
|
||||
131
msg_viewer/static/src/lib/msg-viewer-main/lib/styles/root.css
Normal file
131
msg_viewer/static/src/lib/msg-viewer-main/lib/styles/root.css
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Common utils */
|
||||
.color-text-2 {
|
||||
color: rgb(168, 168, 168);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.flex-b {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
all: unset;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background-color: #235b8d;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-2 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Per element */
|
||||
body {
|
||||
background: rgb(36, 38, 43);
|
||||
color: white;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 5fr;
|
||||
grid-gap: 10px;
|
||||
height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
#info {
|
||||
align-items: center;
|
||||
gap: 50px;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-content h1 {
|
||||
font-size: 2.3rem;
|
||||
}
|
||||
|
||||
.info-content ul {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.info-content ul li {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: #475369;
|
||||
}
|
||||
|
||||
.info-footer {
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: rgb(102, 172, 248);
|
||||
}
|
||||
|
||||
#msg {
|
||||
gap: 10px;
|
||||
border: 3px solid white;
|
||||
border-radius: 6px;
|
||||
background: #333c4d;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#root {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 3fr;
|
||||
}
|
||||
|
||||
#msg {
|
||||
min-height: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@import "./lib/styles/root.css";
|
||||
@import "./lib/components/styles.css";
|
||||
761
msg_viewer/static/src/lib/msg-viewer-main/package-lock.json
generated
Normal file
761
msg_viewer/static/src/lib/msg-viewer-main/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
{
|
||||
"name": "msg-viewer-ui",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "msg-viewer-ui",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@molotochok/msg-viewer": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"bun-plugin-html": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.2.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@molotochok/msg-viewer": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@molotochok/msg-viewer/-/msg-viewer-1.0.3.tgz",
|
||||
"integrity": "sha512-I51IHp//YoQxv/nC5wJHYFtW3U8y7ZP4UGTKByVvmuJwQGimx/1um0Y2X0g5VaWRJiUGtISsojhG0WcNZESSxQ=="
|
||||
},
|
||||
"node_modules/@types/bun": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.10.tgz",
|
||||
"integrity": "sha512-76KYVSwrHwr9zsnk6oLXOGs9KvyBg3U066GLO4rk6JZk1ypEPGCUDZ5yOiESyIHWs9cx9iC8r01utYN32XdmgA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"bun-types": "1.1.29"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.12.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
|
||||
"integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
|
||||
"integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.12.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bun-plugin-html": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clean-css": "^5.3.3",
|
||||
"html-minifier-terser": "^7.2.0",
|
||||
"linkedom": "^0.15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bun-types": {
|
||||
"version": "1.1.29",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.29.tgz",
|
||||
"integrity": "sha512-En3/TzSPMPyl5UlUB1MHzHpcrZDakTm7mS203eLoX1fBoEa3PW+aSS8GAqVJ7Is/m34Z5ogL+ECniLY0uDaCPw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "~20.12.8",
|
||||
"@types/ws": "~8.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/camel-case": {
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pascal-case": "^3.1.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-css": {
|
||||
"version": "5.3.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"source-map": "~0.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.1.0",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.1.0",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cssom": {
|
||||
"version": "0.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.1.0",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-case": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "3.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-minifier-terser": {
|
||||
"version": "7.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camel-case": "^4.1.2",
|
||||
"clean-css": "~5.3.2",
|
||||
"commander": "^10.0.0",
|
||||
"entities": "^4.4.0",
|
||||
"param-case": "^3.0.4",
|
||||
"relateurl": "^0.2.7",
|
||||
"terser": "^5.15.1"
|
||||
},
|
||||
"bin": {
|
||||
"html-minifier-terser": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkedom": {
|
||||
"version": "0.15.6",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"cssom": "^0.5.0",
|
||||
"html-escaper": "^3.0.3",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"uhyphen": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lower-case": {
|
||||
"version": "2.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/no-case": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lower-case": "^2.0.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pascal-case": {
|
||||
"version": "3.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/relateurl": {
|
||||
"version": "0.2.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.31.6",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.7.0",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.5.4",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uhyphen": {
|
||||
"version": "0.2.0",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.3.5",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"dev": true
|
||||
},
|
||||
"@jridgewell/set-array": {
|
||||
"version": "1.2.1",
|
||||
"dev": true
|
||||
},
|
||||
"@jridgewell/source-map": {
|
||||
"version": "0.3.6",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
}
|
||||
},
|
||||
"@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.0",
|
||||
"dev": true
|
||||
},
|
||||
"@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"@molotochok/msg-viewer": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@molotochok/msg-viewer/-/msg-viewer-1.0.3.tgz",
|
||||
"integrity": "sha512-I51IHp//YoQxv/nC5wJHYFtW3U8y7ZP4UGTKByVvmuJwQGimx/1um0Y2X0g5VaWRJiUGtISsojhG0WcNZESSxQ=="
|
||||
},
|
||||
"@types/bun": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.10.tgz",
|
||||
"integrity": "sha512-76KYVSwrHwr9zsnk6oLXOGs9KvyBg3U066GLO4rk6JZk1ypEPGCUDZ5yOiESyIHWs9cx9iC8r01utYN32XdmgA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bun-types": "1.1.29"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.12.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
|
||||
"integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"@types/ws": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
|
||||
"integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"version": "8.12.1",
|
||||
"dev": true
|
||||
},
|
||||
"boolbase": {
|
||||
"version": "1.0.0",
|
||||
"dev": true
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"dev": true
|
||||
},
|
||||
"bun-plugin-html": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"clean-css": "^5.3.3",
|
||||
"html-minifier-terser": "^7.2.0",
|
||||
"linkedom": "^0.15.3"
|
||||
}
|
||||
},
|
||||
"bun-types": {
|
||||
"version": "1.1.29",
|
||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.29.tgz",
|
||||
"integrity": "sha512-En3/TzSPMPyl5UlUB1MHzHpcrZDakTm7mS203eLoX1fBoEa3PW+aSS8GAqVJ7Is/m34Z5ogL+ECniLY0uDaCPw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "~20.12.8",
|
||||
"@types/ws": "~8.5.10"
|
||||
}
|
||||
},
|
||||
"camel-case": {
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"pascal-case": "^3.1.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"clean-css": {
|
||||
"version": "5.3.3",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"source-map": "~0.6.0"
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "10.0.1",
|
||||
"dev": true
|
||||
},
|
||||
"css-select": {
|
||||
"version": "5.1.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"css-what": {
|
||||
"version": "6.1.0",
|
||||
"dev": true
|
||||
},
|
||||
"cssom": {
|
||||
"version": "0.5.0",
|
||||
"dev": true
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"dev": true
|
||||
},
|
||||
"domhandler": {
|
||||
"version": "5.0.3",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "3.1.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
}
|
||||
},
|
||||
"dot-case": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"version": "4.5.0",
|
||||
"dev": true
|
||||
},
|
||||
"html-escaper": {
|
||||
"version": "3.0.3",
|
||||
"dev": true
|
||||
},
|
||||
"html-minifier-terser": {
|
||||
"version": "7.2.0",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"camel-case": "^4.1.2",
|
||||
"clean-css": "~5.3.2",
|
||||
"commander": "^10.0.0",
|
||||
"entities": "^4.4.0",
|
||||
"param-case": "^3.0.4",
|
||||
"relateurl": "^0.2.7",
|
||||
"terser": "^5.15.1"
|
||||
}
|
||||
},
|
||||
"htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"linkedom": {
|
||||
"version": "0.15.6",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"css-select": "^5.1.0",
|
||||
"cssom": "^0.5.0",
|
||||
"html-escaper": "^3.0.3",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"uhyphen": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"lower-case": {
|
||||
"version": "2.0.2",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"no-case": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lower-case": "^2.0.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"nth-check": {
|
||||
"version": "2.1.1",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"param-case": {
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"pascal-case": {
|
||||
"version": "3.1.2",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"relateurl": {
|
||||
"version": "0.2.7",
|
||||
"dev": true
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"dev": true
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"terser": {
|
||||
"version": "5.31.6",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": {
|
||||
"version": "2.20.3",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.7.0",
|
||||
"dev": true
|
||||
},
|
||||
"typescript": {
|
||||
"version": "5.5.4",
|
||||
"peer": true
|
||||
},
|
||||
"uhyphen": {
|
||||
"version": "0.2.0",
|
||||
"dev": true
|
||||
},
|
||||
"undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
20
msg_viewer/static/src/lib/msg-viewer-main/package.json
Normal file
20
msg_viewer/static/src/lib/msg-viewer-main/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "msg-viewer-ui",
|
||||
"version": "1.0.0",
|
||||
"author": "Markiian Karpa",
|
||||
"main": "index.js",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"bun-plugin-html": "^2.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@molotochok/msg-viewer": "^1.0.3"
|
||||
}
|
||||
}
|
||||
36
msg_viewer/static/src/lib/msg-viewer.js
Normal file
36
msg_viewer/static/src/lib/msg-viewer.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Import the required modules
|
||||
import { MsgParser } from './scripts/msg/msg-parser';
|
||||
import { HtmlTemplateUtil } from './scripts/utils/html-template-util';
|
||||
|
||||
// Function to load and display the MSG file
|
||||
async function loadMsgFile(url) {
|
||||
try {
|
||||
// Fetch the MSG file
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Parse the MSG file
|
||||
const parser = new MsgParser(arrayBuffer);
|
||||
const message = await parser.parse();
|
||||
|
||||
// Create HTML from the message
|
||||
const html = HtmlTemplateUtil.createMessageHtml(message);
|
||||
|
||||
// Display the message
|
||||
document.body.innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('Error loading MSG file:', error);
|
||||
document.body.innerHTML = `<div class="error">Error loading MSG file: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the file URL from the query parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const fileUrl = urlParams.get('file');
|
||||
|
||||
// Load the MSG file if a URL is provided
|
||||
if (fileUrl) {
|
||||
loadMsgFile(fileUrl);
|
||||
} else {
|
||||
document.body.innerHTML = '<div class="error">No MSG file URL provided</div>';
|
||||
}
|
||||
BIN
msg_viewer/static/src/lib/msg-viewer.zip
Normal file
BIN
msg_viewer/static/src/lib/msg-viewer.zip
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
google-site-verification: google331b65fa04565532.html
|
||||
2
msg_viewer/static/src/lib/resources/robots.txt
Normal file
2
msg_viewer/static/src/lib/resources/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
63
msg_viewer/static/src/lib/scripts/index.ts
Normal file
63
msg_viewer/static/src/lib/scripts/index.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { messageFragment } from "../components/message";
|
||||
import { errorFragment } from "../components/error";
|
||||
import type { Message } from "./msg/types/message";
|
||||
import { parse, parseDir } from "@molotochok/msg-viewer";
|
||||
|
||||
const $file = document.getElementById("file")!;
|
||||
|
||||
$file.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target?.files?.length === 0) return;
|
||||
updateMessage(target.files!);
|
||||
});
|
||||
|
||||
// To reset the file input
|
||||
$file.addEventListener("click", (event) => (event.target as HTMLInputElement).value = "");
|
||||
|
||||
|
||||
const target = document.documentElement;
|
||||
target.addEventListener("dragover", (event) => event.preventDefault());
|
||||
target.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const files = event.dataTransfer!.files;
|
||||
if (files.length == 0) return;
|
||||
if (!files[0].name.endsWith(".msg")) return;
|
||||
|
||||
const $file = document.getElementById("file")! as HTMLInputElement;
|
||||
$file.files = files;
|
||||
updateMessage(files);
|
||||
});
|
||||
|
||||
async function updateMessage(files: FileList) {
|
||||
const arrayBuffer = await files[0].arrayBuffer();
|
||||
const $msg = document.getElementById("msg")!;
|
||||
renderMessage($msg,
|
||||
() => parse(new DataView(arrayBuffer)),
|
||||
(fragment) => $msg.replaceChildren(fragment)
|
||||
);
|
||||
}
|
||||
|
||||
function renderMessage($msg: HTMLElement, getMessage: () => Message, updateDom: (fragment: DocumentFragment) => void) {
|
||||
let fragment: DocumentFragment;
|
||||
try {
|
||||
const message = getMessage();
|
||||
fragment = messageFragment(message, dir => {
|
||||
renderMessage($msg,
|
||||
() => parseDir(message.file, dir),
|
||||
(fragment) => {
|
||||
for (let i = 0; i < $msg.children.length; i++) {
|
||||
const child = $msg.children[i] as HTMLElement;
|
||||
child.classList.add("hidden");
|
||||
};
|
||||
$msg.appendChild(fragment)
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
window.gtag('event', 'exception', { 'description': e, 'fatal': true });
|
||||
fragment = errorFragment(`An error occured during the parsing of the .msg file. Error: ${e}`);
|
||||
}
|
||||
|
||||
updateDom(fragment);
|
||||
}
|
||||
37
msg_viewer/static/src/lib/scripts/msg/README.md
Normal file
37
msg_viewer/static/src/lib/scripts/msg/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<div align="center">
|
||||
<img src="https://github.com/user-attachments/assets/f065cc3a-c40b-4917-ac51-006cfbc78f0f" alt="Icon"/>
|
||||
<p><strong>https://msg-viewer.pages.dev/</strong><p>
|
||||
</div>
|
||||
|
||||
### Description
|
||||
This library allows you to read Outlook `.msg` file in browser.
|
||||
|
||||
### Install
|
||||
Simply run this command in terminal:
|
||||
```
|
||||
npm i @molotochok/msg-viewer
|
||||
```
|
||||
|
||||
### Usage
|
||||
First import the library:
|
||||
|
||||
```js
|
||||
import { parse } from "@molotochok/msg-viewer";
|
||||
```
|
||||
|
||||
Then parse the input array buffer:
|
||||
```js
|
||||
const message = parse(new DataView(arrayBuffer));
|
||||
```
|
||||
|
||||
The result is an object that contains relevant information about the message. It's structure is defined [here](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/msg/types/message.d.ts#L1).
|
||||
|
||||
### Example
|
||||
Refer to this [page](https://github.com/molotochok/msg-viewer/blob/main/lib/scripts/index.ts#L34) for the example usage of this library in the live experience.
|
||||
|
||||
|
||||
### Repository
|
||||
You can find the source code on [Github](https://github.com/molotochok/msg-viewer).
|
||||
|
||||
### Support
|
||||
If you wish to support me you can by me a [coffee](https://buymeacoffee.com/markian98f).
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { getDifat } from "./difat";
|
||||
import { Directory } from "./directory/directory";
|
||||
import type { DirectoryEntry } from "./directory/types/directory-entry";
|
||||
import { getFat } from "./fat";
|
||||
import { getHeader, type Header } from "./header";
|
||||
import { getMiniFat } from "./mini-fat";
|
||||
import { streamSectorOffset } from "./util";
|
||||
|
||||
export class CompoundFile {
|
||||
constructor(
|
||||
public readonly view: DataView,
|
||||
public readonly header: Header,
|
||||
public readonly difat: number[],
|
||||
public readonly fat: number[],
|
||||
public readonly miniFat: number[],
|
||||
public readonly directory: Directory,
|
||||
) {}
|
||||
|
||||
static create(view: DataView) {
|
||||
const header = getHeader(view);
|
||||
const difat = getDifat(view, header);
|
||||
const fat = getFat(view, header, difat);
|
||||
const miniFat = getMiniFat(view, header, fat);
|
||||
const directory = Directory.getDirectory(view, header, fat);
|
||||
|
||||
return new CompoundFile(view, header, difat, fat, miniFat, directory);
|
||||
}
|
||||
|
||||
readStream(
|
||||
entry: DirectoryEntry,
|
||||
getDataAction: (offset: number, bytesToRead: number) => void,
|
||||
entrySize?: number,
|
||||
getHeaderAction?: (offset: number) => number
|
||||
) {
|
||||
let sector = entry.startingSectorLocation;
|
||||
if (sector >= 0xFFFFFFFE) return;
|
||||
|
||||
let sectorSize = this.header.sectorSize;
|
||||
let fat = this.fat;
|
||||
|
||||
if (entry.streamSize < this.header.miniStreamCutOffSize) {
|
||||
sectorSize = this.header.miniSectorSize;
|
||||
fat = this.miniFat;
|
||||
}
|
||||
|
||||
let offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations);
|
||||
let initialOffset = offset;
|
||||
|
||||
const headerSize = getHeaderAction ? getHeaderAction(offset) : 0;
|
||||
let streamSize = entry.streamSize - BigInt(headerSize);
|
||||
offset += headerSize;
|
||||
entrySize = entrySize ?? Math.min(Number(streamSize), this.header.miniSectorSize);
|
||||
|
||||
while(streamSize > 0) {
|
||||
if (offset - initialOffset >= sectorSize) {
|
||||
sector = fat[sector];
|
||||
if (sector >= 0xFFFFFFFE) break;
|
||||
|
||||
offset = streamSectorOffset(sector, this.header, entry.streamSize, this.directory.miniStreamLocations);
|
||||
initialOffset = offset;
|
||||
}
|
||||
|
||||
let bytes = Math.min(entrySize, Number(streamSize));
|
||||
getDataAction(offset, bytes);
|
||||
|
||||
streamSize -= BigInt(bytes);
|
||||
offset += bytes;
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const obj = {
|
||||
header: this.header,
|
||||
difat: this.difat,
|
||||
fat: this.fat,
|
||||
miniFat:
|
||||
this.miniFat,
|
||||
directory:
|
||||
this.directory
|
||||
};
|
||||
|
||||
return JSON.stringify(obj, (_, v) => typeof v === 'bigint' ? v.toString() : v, "\t");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const TEXT_DECODER = new TextDecoder("UTF-16LE");
|
||||
34
msg_viewer/static/src/lib/scripts/msg/compound-file/difat.ts
Normal file
34
msg_viewer/static/src/lib/scripts/msg/compound-file/difat.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { Header } from "./header";
|
||||
import { sectorOffset } from "./util";
|
||||
|
||||
/**
|
||||
* Returns an array of FAT Sector Locations which specify the FAT sector number.
|
||||
* DIFAT is used to find all FAT sectors.
|
||||
*/
|
||||
export function getDifat(view: DataView, header: Header): number[] {
|
||||
// - If Header Major Version is 3, there MUST be 127 fields specified to fill a 512-byte sector minus the "Next DIFAT Sector Location" field.
|
||||
// - If Header Major Version is 4, there MUST be 1,023 fields specified to fill a 4,096-byte sector minus the "Next DIFAT Sector Location" field.
|
||||
const fatNumber = header.majorVersion == 3 ? 127 : 1023;
|
||||
|
||||
const difat = Array.from(header.difat);
|
||||
|
||||
// Next DIFAT Sector Location (4 bytes): This field specifies the next sector number in the DIFAT chain of sectors. The first DIFAT sector is specified in the Header. The last DIFAT sector MUST set this field to ENDOFCHAIN (0xFFFFFFFE).
|
||||
let sector = header.firstDifatSectorLocation;
|
||||
while (sector < 0xFFFFFFFE) {
|
||||
let offset = sectorOffset(sector, header.sectorSize);
|
||||
|
||||
for (let i = 0; i < fatNumber; i++) {
|
||||
if (view.getUint32(offset, true) == 0xFFFFFFFF) {
|
||||
offset += (fatNumber - i) * 4;
|
||||
break;
|
||||
}
|
||||
|
||||
difat.push(view.getUint32(offset, true));
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
sector = view.getUint32(offset, true);
|
||||
}
|
||||
|
||||
return difat;
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { ColorFlag } from "./enums/color-flag";
|
||||
import type { DirectoryEntry } from "./types/directory-entry";
|
||||
import { ObjectType } from "./enums/object-type";
|
||||
import type { Header } from "../header";
|
||||
import { sectorOffset } from "../util";
|
||||
import { TEXT_DECODER } from "../constants/text-decoder";
|
||||
|
||||
export class Directory {
|
||||
constructor(
|
||||
public readonly entries: DirectoryEntry[],
|
||||
public readonly miniStreamLocations: number[]
|
||||
) {}
|
||||
|
||||
static getDirectory(view: DataView, header: Header, fat: number[]): Directory {
|
||||
const entrySize = 128;
|
||||
const entriesCount = header.sectorSize / entrySize;
|
||||
|
||||
const entries: DirectoryEntry[] = [];
|
||||
|
||||
let sector = header.firstDirSectorLocation;
|
||||
while (sector < 0xFFFFFFFE) {
|
||||
let offset = sectorOffset(sector, header.sectorSize);
|
||||
|
||||
for (let i = 0; i < entriesCount; i++) {
|
||||
entries.push(this.directoryEntry(view, offset));
|
||||
|
||||
offset += entrySize;
|
||||
}
|
||||
|
||||
sector = fat[sector];
|
||||
}
|
||||
|
||||
return new Directory(
|
||||
entries,
|
||||
this.getMiniStreamLocations(entries[0].startingSectorLocation, fat),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses a Red-Black Tree to find the entry with the given name.
|
||||
*/
|
||||
get(name: string, root: number, deep: boolean): DirectoryEntry | null {
|
||||
if (root < 0 || root >= this.entries.length) return null;
|
||||
|
||||
const entry = this.entries[root];
|
||||
if (!entry) return null;
|
||||
|
||||
const diff = this.compareName(name, entry.entryName);
|
||||
|
||||
if (diff < 0) {
|
||||
const left = this.get(name, entry.leftSiblingId, deep);
|
||||
if (left) return left;
|
||||
}else if (diff > 0) {
|
||||
const right = this.get(name, entry.rightSiblingId, deep);
|
||||
if (right) return right;
|
||||
} else {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return deep ? this.get(name, entry.childId, deep) : null;
|
||||
}
|
||||
|
||||
private static getMiniStreamLocations(sector: number, fat: number[]): number[] {
|
||||
const locations: number[] = [];
|
||||
|
||||
while(sector < 0xFFFFFFFE) {
|
||||
locations.push(sector);
|
||||
sector = fat[sector];
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
private static directoryEntry(view: DataView, offset: number): DirectoryEntry {
|
||||
const entryNameLength = view.getUint16(offset + 64, true);
|
||||
const entryName = entryNameLength > 0
|
||||
? TEXT_DECODER.decode(new DataView(view.buffer, offset, entryNameLength - 2))
|
||||
: "";
|
||||
offset += 66;
|
||||
|
||||
const objectType = view.getUint8(offset) as ObjectType;
|
||||
offset += 1;
|
||||
|
||||
const colorFlag = view.getUint8(offset) as ColorFlag;
|
||||
offset += 1;
|
||||
|
||||
const leftSiblingId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const rightSiblingId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const childId = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const clsid = "";
|
||||
offset += 16;
|
||||
|
||||
const stateBits = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const creationTime = view.getBigUint64(offset, true);
|
||||
offset += 8;
|
||||
|
||||
const modifiedTime = view.getBigUint64(offset, true);
|
||||
offset += 8;
|
||||
|
||||
const startingSectorLocation = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
|
||||
const streamSize = view.getBigUint64(offset, true);
|
||||
offset += 8;
|
||||
|
||||
return {
|
||||
entryName,
|
||||
entryNameLength,
|
||||
objectType,
|
||||
colorFlag,
|
||||
leftSiblingId,
|
||||
rightSiblingId,
|
||||
childId,
|
||||
clsid,
|
||||
stateBits,
|
||||
creationTime,
|
||||
modifiedTime,
|
||||
startingSectorLocation,
|
||||
streamSize
|
||||
}
|
||||
}
|
||||
|
||||
print() {
|
||||
this.traverse((entry, depth) => console.log("\t".repeat(depth), entry.entryName));
|
||||
}
|
||||
|
||||
traverse(action: (entry: DirectoryEntry, depth: number) => void) {
|
||||
this.traverseFromRoot(0, 0, action);
|
||||
}
|
||||
|
||||
private traverseFromRoot(root: number, depth: number, action: (entry: DirectoryEntry, depth: number) => void) {
|
||||
if (root < 0 || root >= this.entries.length) return;
|
||||
|
||||
const entry = this.entries[root];
|
||||
if (!entry) return;
|
||||
|
||||
this.traverseFromRoot(entry.leftSiblingId, depth, action);
|
||||
this.traverseFromRoot(entry.rightSiblingId, depth, action);
|
||||
|
||||
action(entry, depth);
|
||||
this.traverseFromRoot(entry.childId, depth + 1, action);
|
||||
}
|
||||
|
||||
private compareName(name1: string, name2: string) {
|
||||
if (name1.length < name2.length) return -1;
|
||||
if (name1.length > name2.length) return 1;
|
||||
return name1.toUpperCase().localeCompare(name2.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export enum ColorFlag {
|
||||
Red = 0x00,
|
||||
Black = 0x01
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue