Module: Philiprehberger::MimeType

Defined in:
lib/philiprehberger/mime_type.rb,
lib/philiprehberger/mime_type/magic.rb,
lib/philiprehberger/mime_type/version.rb,
lib/philiprehberger/mime_type/extensions.rb

Defined Under Namespace

Classes: Error

Constant Summary collapse

MAGIC_SIGNATURES =

Magic byte signatures for content-based detection Each entry: [offset, byte_pattern, mime_type]

[
  # Images
  [0, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'image/png'],
  [0, [0xFF, 0xD8, 0xFF], 'image/jpeg'],
  [0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], 'image/gif'],
  [0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 'image/gif'],
  [0, [0x42, 0x4D], 'image/bmp'],
  [0, [0x00, 0x00, 0x01, 0x00], 'image/x-icon'],
  [0, [0x00, 0x00, 0x02, 0x00], 'image/x-icon'],
  [8, [0x57, 0x45, 0x42, 0x50], 'image/webp'],
  [4, [0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63], 'image/heic'],
  [0, [0x49, 0x49, 0x2A, 0x00], 'image/tiff'],
  [0, [0x4D, 0x4D, 0x00, 0x2A], 'image/tiff'],

  # Documents
  [0, [0x25, 0x50, 0x44, 0x46], 'application/pdf'],
  [0, [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1], 'application/msword'],

  # Archives
  [0, [0x50, 0x4B, 0x03, 0x04], 'application/zip'],
  [0, [0x50, 0x4B, 0x05, 0x06], 'application/zip'],
  [0, [0x50, 0x4B, 0x07, 0x08], 'application/zip'],
  [0, [0x1F, 0x8B], 'application/gzip'],
  [0, [0x42, 0x5A, 0x68], 'application/x-bzip2'],
  [0, [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed'],
  [0, [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar'],
  [0, [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], 'application/x-xz'],
  [257, [0x75, 0x73, 0x74, 0x61, 0x72], 'application/x-tar'],

  # Audio
  [0, [0x49, 0x44, 0x33], 'audio/mpeg'],
  [0, [0xFF, 0xFB], 'audio/mpeg'],
  [0, [0xFF, 0xF3], 'audio/mpeg'],
  [0, [0xFF, 0xF2], 'audio/mpeg'],
  [0, [0x52, 0x49, 0x46, 0x46], 'audio/wav'],
  [0, [0x4F, 0x67, 0x67, 0x53], 'audio/ogg'],
  [0, [0x66, 0x4C, 0x61, 0x43], 'audio/flac'],

  # Video
  [4, [0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D], 'video/mp4'],
  [4, [0x66, 0x74, 0x79, 0x70, 0x4D, 0x53, 0x4E, 0x56], 'video/mp4'],
  [4, [0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32], 'video/mp4'],
  [0, [0x1A, 0x45, 0xDF, 0xA3], 'video/webm'],
  [0, [0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70], 'video/mp4'],

  # Fonts
  [0, [0x77, 0x4F, 0x46, 0x46], 'font/woff'],
  [0, [0x77, 0x4F, 0x46, 0x32], 'font/woff2'],
  [0, [0x00, 0x01, 0x00, 0x00], 'font/ttf'],
  [0, [0x4F, 0x54, 0x54, 0x4F], 'font/otf'],

  # Binary
  [0, [0x4D, 0x5A], 'application/vnd.microsoft.portable-executable'],
  [0, [0x7F, 0x45, 0x4C, 0x46], 'application/x-elf'],
  [0, [0xCA, 0xFE, 0xBA, 0xBE], 'application/java-vm'],

  # WebAssembly
  [0, [0x00, 0x61, 0x73, 0x6D], 'application/wasm'],

  # SQLite
  [0, [0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6F, 0x72, 0x6D, 0x61, 0x74], 'application/vnd.sqlite3']
].freeze
VERSION =
'0.3.0'
EXTENSION_MAP =

Extension to MIME type mappings

{
  # Text
  '.txt' => 'text/plain',
  '.html' => 'text/html',
  '.htm' => 'text/html',
  '.css' => 'text/css',
  '.csv' => 'text/csv',
  '.tsv' => 'text/tab-separated-values',
  '.xml' => 'text/xml',
  '.rtf' => 'text/rtf',
  '.md' => 'text/markdown',
  '.yaml' => 'text/yaml',
  '.yml' => 'text/yaml',
  '.ics' => 'text/calendar',
  '.vcf' => 'text/vcard',
  '.vtt' => 'text/vtt',

  # JavaScript / JSON
  '.js' => 'text/javascript',
  '.mjs' => 'text/javascript',
  '.json' => 'application/json',
  '.jsonld' => 'application/ld+json',
  '.map' => 'application/json',

  # Images
  '.png' => 'image/png',
  '.jpg' => 'image/jpeg',
  '.jpeg' => 'image/jpeg',
  '.gif' => 'image/gif',
  '.bmp' => 'image/bmp',
  '.ico' => 'image/x-icon',
  '.svg' => 'image/svg+xml',
  '.webp' => 'image/webp',
  '.avif' => 'image/avif',
  '.tiff' => 'image/tiff',
  '.tif' => 'image/tiff',
  '.heic' => 'image/heic',
  '.heif' => 'image/heif',
  '.jxl' => 'image/jxl',
  '.apng' => 'image/apng',
  '.psd' => 'image/vnd.adobe.photoshop',

  # Audio
  '.mp3' => 'audio/mpeg',
  '.wav' => 'audio/wav',
  '.ogg' => 'audio/ogg',
  '.oga' => 'audio/ogg',
  '.flac' => 'audio/flac',
  '.aac' => 'audio/aac',
  '.m4a' => 'audio/mp4',
  '.wma' => 'audio/x-ms-wma',
  '.opus' => 'audio/opus',
  '.mid' => 'audio/midi',
  '.midi' => 'audio/midi',
  '.weba' => 'audio/webm',
  '.aiff' => 'audio/aiff',

  # Video
  '.mp4' => 'video/mp4',
  '.m4v' => 'video/mp4',
  '.avi' => 'video/x-msvideo',
  '.mov' => 'video/quicktime',
  '.wmv' => 'video/x-ms-wmv',
  '.flv' => 'video/x-flv',
  '.webm' => 'video/webm',
  '.mkv' => 'video/x-matroska',
  '.ogv' => 'video/ogg',
  '.mpeg' => 'video/mpeg',
  '.mpg' => 'video/mpeg',
  '.3gp' => 'video/3gpp',
  '.3g2' => 'video/3gpp2',
  '.m2ts' => 'video/mp2t',
  '.m3u8' => 'application/vnd.apple.mpegurl',

  # Application / Documents
  '.pdf' => 'application/pdf',
  '.doc' => 'application/msword',
  '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  '.xls' => 'application/vnd.ms-excel',
  '.xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  '.ppt' => 'application/vnd.ms-powerpoint',
  '.pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  '.odt' => 'application/vnd.oasis.opendocument.text',
  '.ods' => 'application/vnd.oasis.opendocument.spreadsheet',
  '.odp' => 'application/vnd.oasis.opendocument.presentation',
  '.epub' => 'application/epub+zip',

  # Archives
  '.zip' => 'application/zip',
  '.gz' => 'application/gzip',
  '.gzip' => 'application/gzip',
  '.tar' => 'application/x-tar',
  '.bz2' => 'application/x-bzip2',
  '.7z' => 'application/x-7z-compressed',
  '.rar' => 'application/vnd.rar',
  '.xz' => 'application/x-xz',
  '.zst' => 'application/zstd',
  '.lz' => 'application/x-lzip',
  '.lzma' => 'application/x-lzma',
  '.cab' => 'application/vnd.ms-cab-compressed',
  '.dmg' => 'application/x-apple-diskimage',
  '.iso' => 'application/x-iso9660-image',

  # Executables / Binary
  '.exe' => 'application/vnd.microsoft.portable-executable',
  '.dll' => 'application/vnd.microsoft.portable-executable',
  '.deb' => 'application/vnd.debian.binary-package',
  '.rpm' => 'application/x-rpm',
  '.msi' => 'application/x-msi',
  '.apk' => 'application/vnd.android.package-archive',
  '.jar' => 'application/java-archive',
  '.war' => 'application/java-archive',
  '.class' => 'application/java-vm',

  # Fonts
  '.woff' => 'font/woff',
  '.woff2' => 'font/woff2',
  '.ttf' => 'font/ttf',
  '.otf' => 'font/otf',
  '.eot' => 'application/vnd.ms-fontobject',

  # Data / Config
  '.sql' => 'application/sql',
  '.sqlite' => 'application/vnd.sqlite3',
  '.db' => 'application/octet-stream',
  '.wasm' => 'application/wasm',
  '.graphql' => 'application/graphql',
  '.toml' => 'application/toml',

  # Programming
  '.rb' => 'text/x-ruby',
  '.py' => 'text/x-python',
  '.java' => 'text/x-java-source',
  '.c' => 'text/x-c',
  '.cpp' => 'text/x-c++',
  '.h' => 'text/x-c',
  '.hpp' => 'text/x-c++',
  '.rs' => 'text/x-rust',
  '.go' => 'text/x-go',
  '.swift' => 'text/x-swift',
  '.kt' => 'text/x-kotlin',
  '.ts' => 'text/typescript',
  '.tsx' => 'text/tsx',
  '.jsx' => 'text/jsx',
  '.php' => 'text/x-php',
  '.sh' => 'application/x-sh',
  '.bash' => 'application/x-sh',
  '.bat' => 'application/x-msdos-program',
  '.ps1' => 'application/x-powershell',
  '.lua' => 'text/x-lua',
  '.r' => 'text/x-r',
  '.scala' => 'text/x-scala',
  '.pl' => 'text/x-perl',
  '.dart' => 'application/vnd.dart',
  '.ex' => 'text/x-elixir',
  '.erl' => 'text/x-erlang',
  '.hs' => 'text/x-haskell',
  '.clj' => 'text/x-clojure',
  '.vim' => 'text/x-vim',
  '.dockerfile' => 'text/x-dockerfile',

  # Misc
  '.swf' => 'application/x-shockwave-flash',
  '.atom' => 'application/atom+xml',
  '.rss' => 'application/rss+xml',
  '.m3u' => 'audio/x-mpegurl',
  '.pls' => 'audio/x-scpls',
  '.torrent' => 'application/x-bittorrent',
  '.bin' => 'application/octet-stream',
  '.dat' => 'application/octet-stream',
  '.crt' => 'application/x-x509-ca-cert',
  '.pem' => 'application/x-pem-file',
  '.p12' => 'application/x-pkcs12',
  '.key' => 'application/x-pem-file',
  '.asc' => 'application/pgp-signature',
  '.gpg' => 'application/pgp-encrypted',
  '.sig' => 'application/pgp-signature',
  '.eml' => 'message/rfc822',
  '.mbox' => 'application/mbox',
  '.xpi' => 'application/x-xpinstall',
  '.crx' => 'application/x-chrome-extension'
}.freeze
MIME_TO_EXTENSIONS =

Reverse mapping: MIME type to extensions

EXTENSION_MAP.each_with_object({}) do |(ext, mime), hash|
  (hash[mime] ||= []) << ext
end.freeze

Class Method Summary collapse

Class Method Details

.application?(mime) ⇒ Boolean

Check if a MIME type is an application type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for application/* MIME types



165
166
167
# File 'lib/philiprehberger/mime_type.rb', line 165

def self.application?(mime)
  category_prefix?(mime, 'application')
end

.audio?(mime) ⇒ Boolean

Check if a MIME type is an audio type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for audio/* MIME types



149
150
151
# File 'lib/philiprehberger/mime_type.rb', line 149

def self.audio?(mime)
  category_prefix?(mime, 'audio')
end

.best_match(available, accept_header) ⇒ String?

Content negotiation: find the best matching MIME type

Parameters:

  • available (Array<String>)

    available MIME types

  • accept_header (String)

    Accept header value

Returns:

  • (String, nil)

    best matching MIME type or nil



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/philiprehberger/mime_type.rb', line 234

def self.best_match(available, accept_header)
  return nil if available.nil? || available.empty?

  parsed = parse_accept(accept_header)
  return nil if parsed.empty?

  parsed.each do |entry|
    accepted = entry[:type]

    if accepted == '*/*'
      return available.first
    end

    # Check for type/* wildcard
    if accepted.end_with?('/*')
      prefix = accepted.split('/').first
      match = available.find { |a| a.start_with?("#{prefix}/") }
      return match if match
    elsif available.include?(accepted)
      return accepted
    end
  end

  nil
end

.binary?(mime) ⇒ Boolean

Check if a MIME type is a binary (non-text) type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for non-text MIME types



197
198
199
200
201
202
# File 'lib/philiprehberger/mime_type.rb', line 197

def self.binary?(mime)
  type = mime.to_s.strip.downcase
  return false if type.empty?

  !type.start_with?('text/')
end

.category(mime) ⇒ Symbol?

Get the category of a MIME type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Symbol, nil)

    category (:text, :image, :audio, :video, :application, :font, :message)



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/philiprehberger/mime_type.rb', line 89

def self.category(mime)
  type = mime.to_s.strip.downcase
  return nil if type.empty?

  major = type.split('/').first
  case major
  when 'text' then :text
  when 'image' then :image
  when 'audio' then :audio
  when 'video' then :video
  when 'application' then :application
  when 'font' then :font
  when 'message' then :message
  when 'multipart' then :multipart
  when 'model' then :model
  end
end

.charset(mime) ⇒ String?

Return the default charset for a MIME type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (String, nil)

    “utf-8” for text types, nil for binary types



122
123
124
125
126
127
# File 'lib/philiprehberger/mime_type.rb', line 122

def self.charset(mime)
  type = mime.to_s.strip.downcase
  return nil if type.empty?

  type.start_with?('text/') ? 'utf-8' : nil
end

.extensions(mime) ⇒ Array<String>

Get file extensions for a MIME type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Array<String>)

    list of extensions (empty if unknown)



81
82
83
# File 'lib/philiprehberger/mime_type.rb', line 81

def self.extensions(mime)
  MIME_TO_EXTENSIONS[mime.to_s.strip.downcase] || []
end

.font?(mime) ⇒ Boolean

Check if a MIME type is a font type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for font/* MIME types



173
174
175
# File 'lib/philiprehberger/mime_type.rb', line 173

def self.font?(mime)
  category_prefix?(mime, 'font')
end

.for_content(bytes) ⇒ String?

Detect MIME type from file content using magic bytes

Parameters:

  • bytes (String)

    binary content (first 300+ bytes recommended)

Returns:

  • (String, nil)

    MIME type or nil if unrecognized



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/philiprehberger/mime_type.rb', line 59

def self.for_content(bytes)
  return nil if bytes.nil? || bytes.empty?

  raw = bytes.b

  MAGIC_SIGNATURES.each do |offset, pattern, mime|
    next if raw.length < offset + pattern.length

    match = pattern.each_with_index.all? do |byte, i|
      raw.getbyte(offset + i) == byte
    end

    return mime if match
  end

  nil
end

.for_extension(ext) ⇒ String?

Detect MIME type from a file extension

Parameters:

  • ext (String)

    file extension (with or without leading dot)

Returns:

  • (String, nil)

    MIME type or nil if unknown



38
39
40
41
42
# File 'lib/philiprehberger/mime_type.rb', line 38

def self.for_extension(ext)
  normalized = ext.to_s.strip.downcase
  normalized = ".#{normalized}" unless normalized.start_with?('.')
  @custom_extensions[normalized] || EXTENSION_MAP[normalized]
end

.for_filename(name) ⇒ String?

Detect MIME type from a filename

Parameters:

  • name (String)

    filename with extension

Returns:

  • (String, nil)

    MIME type or nil if unknown



48
49
50
51
52
53
# File 'lib/philiprehberger/mime_type.rb', line 48

def self.for_filename(name)
  ext = File.extname(name.to_s.strip).downcase
  return nil if ext.empty?

  @custom_extensions[ext] || EXTENSION_MAP[ext]
end

.image?(mime) ⇒ Boolean

Check if a MIME type is an image type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for image/* MIME types



141
142
143
# File 'lib/philiprehberger/mime_type.rb', line 141

def self.image?(mime)
  category_prefix?(mime, 'image')
end

.message?(mime) ⇒ Boolean

Check if a MIME type is a message type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for message/* MIME types



189
190
191
# File 'lib/philiprehberger/mime_type.rb', line 189

def self.message?(mime)
  category_prefix?(mime, 'message')
end

.multipart?(mime) ⇒ Boolean

Check if a MIME type is a multipart type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for multipart/* MIME types



181
182
183
# File 'lib/philiprehberger/mime_type.rb', line 181

def self.multipart?(mime)
  category_prefix?(mime, 'multipart')
end

.parse_accept(header) ⇒ Array<Hash>

Parse an HTTP Accept header string

Parameters:

  • header (String)

    Accept header value

Returns:

  • (Array<Hash>)

    array of { type: String, q: Float } sorted by quality descending



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/philiprehberger/mime_type.rb', line 208

def self.parse_accept(header)
  return [] if header.nil? || header.strip.empty?

  entries = header.split(',').map do |part|
    segments = part.strip.split(';').map(&:strip)
    type = segments.first
    q = 1.0

    segments[1..].each do |param|
      key, value = param.split('=', 2).map(&:strip)
      if key == 'q'
        q = value.to_f
      end
    end

    { type: type, q: q }
  end

  entries.sort_by { |e| -e[:q] }
end

.register(ext, mime_type) ⇒ void

This method returns an undefined value.

Register a custom MIME type mapping for an extension

Parameters:

  • ext (String)

    file extension (with or without leading dot)

  • mime_type (String)

    MIME type string



18
19
20
21
22
# File 'lib/philiprehberger/mime_type.rb', line 18

def self.register(ext, mime_type)
  normalized = ext.to_s.strip.downcase
  normalized = ".#{normalized}" unless normalized.start_with?('.')
  @custom_extensions[normalized] = mime_type
end

.text?(mime) ⇒ Boolean

Check if a MIME type is a text type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for text/* MIME types



133
134
135
# File 'lib/philiprehberger/mime_type.rb', line 133

def self.text?(mime)
  category_prefix?(mime, 'text')
end

.unregister(ext) ⇒ void

This method returns an undefined value.

Remove a custom registration for an extension

Parameters:

  • ext (String)

    file extension (with or without leading dot)



28
29
30
31
32
# File 'lib/philiprehberger/mime_type.rb', line 28

def self.unregister(ext)
  normalized = ext.to_s.strip.downcase
  normalized = ".#{normalized}" unless normalized.start_with?('.')
  @custom_extensions.delete(normalized)
end

.valid?(mime) ⇒ Boolean

Check if a MIME type string is valid

Parameters:

  • mime (String)

    MIME type to validate

Returns:

  • (Boolean)

    true if the format is valid



111
112
113
114
115
116
# File 'lib/philiprehberger/mime_type.rb', line 111

def self.valid?(mime)
  type = mime.to_s.strip
  return false if type.empty?

  type.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9!\#$\-^_.+]*/[a-zA-Z0-9][a-zA-Z0-9!\#$\-^_.+]*\z})
end

.video?(mime) ⇒ Boolean

Check if a MIME type is a video type

Parameters:

  • mime (String)

    MIME type

Returns:

  • (Boolean)

    true for video/* MIME types



157
158
159
# File 'lib/philiprehberger/mime_type.rb', line 157

def self.video?(mime)
  category_prefix?(mime, 'video')
end