mirror of
https://github.com/btouchard/ackify.git
synced 2026-05-24 10:58:50 -05:00
feat(storage): improve MIME type detection and add ODF format support
- Add extension-based MIME type refinement for text formats (.md, .docx, .xlsx, .odt, .ods) - Add charset=utf-8 for text-based MIME types in Content-Type header - Support ODF formats (OpenDocument Text/Spreadsheet) - Unify compose templates into single compose.yml.template with region markers - Add update mode to install script to preserve existing configuration - Extend file upload accept list in DocumentCreateForm - Remove binary file from repository
This commit is contained in:
Binary file not shown.
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,6 +21,66 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// extensionMIMETypes maps file extensions to their correct MIME types
|
||||
// Used to override incorrect detection from http.DetectContentType
|
||||
var extensionMIMETypes = map[string]string{
|
||||
// Text formats (detected as text/plain)
|
||||
".md": "text/markdown",
|
||||
".markdown": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
|
||||
// XML-based formats (detected as text/xml)
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".svg": "image/svg+xml",
|
||||
|
||||
// ZIP-based formats (detected as application/zip)
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".odt": "application/vnd.oasis.opendocument.text",
|
||||
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
}
|
||||
|
||||
// ambiguousDetectedTypes lists MIME types from http.DetectContentType that
|
||||
// should be refined using file extension
|
||||
var ambiguousDetectedTypes = map[string]bool{
|
||||
"text/plain": true, // .md, .txt detected as this
|
||||
"text/xml": true, // .svg, .html sometimes detected as this
|
||||
"application/octet-stream": true, // fallback type
|
||||
"application/zip": true, // .docx, .xlsx, .odt, .ods detected as this
|
||||
"application/x-zip": true, // alternative zip type
|
||||
"application/x-gzip": true, // some zip variants
|
||||
}
|
||||
|
||||
// isTextBasedMIME returns true if the MIME type is text-based and needs charset
|
||||
func isTextBasedMIME(mimeType string) bool {
|
||||
return strings.HasPrefix(mimeType, "text/") ||
|
||||
mimeType == "application/json" ||
|
||||
mimeType == "application/xml" ||
|
||||
mimeType == "image/svg+xml"
|
||||
}
|
||||
|
||||
// refineContentType improves content type detection using file extension
|
||||
// when content detection returns an ambiguous type
|
||||
func refineContentType(detectedType, filename string) string {
|
||||
// Extract base MIME type without parameters
|
||||
baseType := strings.Split(detectedType, ";")[0]
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
// If detection gave a specific non-ambiguous type, trust it
|
||||
if !ambiguousDetectedTypes[baseType] {
|
||||
return baseType
|
||||
}
|
||||
|
||||
// For ambiguous types, use file extension to determine correct type
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if mimeType, ok := extensionMIMETypes[ext]; ok {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
return baseType
|
||||
}
|
||||
|
||||
type documentService interface {
|
||||
CreateDocument(ctx context.Context, req services.CreateDocumentRequest) (*models.Document, error)
|
||||
GetByDocID(ctx context.Context, docID string) (*models.Document, error)
|
||||
@@ -99,14 +160,17 @@ func (h *Handler) HandleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
title = header.Filename
|
||||
}
|
||||
|
||||
// Detect content type
|
||||
// Detect content type from file content
|
||||
buffer := make([]byte, 512)
|
||||
n, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
shared.WriteInternalError(w)
|
||||
return
|
||||
}
|
||||
contentType := http.DetectContentType(buffer[:n])
|
||||
detectedType := http.DetectContentType(buffer[:n])
|
||||
|
||||
// Refine content type using file extension for text-based formats
|
||||
contentType := refineContentType(detectedType, header.Filename)
|
||||
|
||||
// Validate content type
|
||||
if !storage.IsAllowedMIMEType(contentType) {
|
||||
@@ -241,8 +305,12 @@ func (h *Handler) HandleContent(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:")
|
||||
}
|
||||
|
||||
// Set content headers
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
// Set content headers with charset for text-based formats
|
||||
finalContentType := contentType
|
||||
if isTextBasedMIME(contentType) {
|
||||
finalContentType = contentType + "; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", finalContentType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
|
||||
// Set content disposition based on query param
|
||||
|
||||
@@ -4,6 +4,7 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
@@ -30,12 +31,19 @@ var AllowedMIMETypes = map[string]bool{
|
||||
"text/plain": true,
|
||||
"text/html": true,
|
||||
"text/markdown": true,
|
||||
"application/msword": true,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
"application/vnd.ms-excel": true,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||
"text/x-markdown": true, // Alternative MIME type for markdown
|
||||
"application/msword": true, // .doc
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, // .docx
|
||||
"application/vnd.ms-excel": true, // .xls
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, // .xlsx
|
||||
"application/vnd.oasis.opendocument.text": true, // .odt
|
||||
"application/vnd.oasis.opendocument.spreadsheet": true, // .ods
|
||||
}
|
||||
|
||||
func IsAllowedMIMEType(mimeType string) bool {
|
||||
return AllowedMIMETypes[mimeType]
|
||||
mediaType, _, err := mime.ParseMediaType(mimeType)
|
||||
if err != nil {
|
||||
return AllowedMIMETypes[mimeType]
|
||||
}
|
||||
return AllowedMIMETypes[mediaType]
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
## SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: ackify-ce
|
||||
|
||||
services:
|
||||
ackify-migrate:
|
||||
image: btouchard/ackify-ce:latest
|
||||
container_name: ackify-migrate
|
||||
environment:
|
||||
ACKIFY_DB_DSN: "postgres://postgres:${POSTGRES_PASSWORD}@ackify-db:5432/ackify?sslmode=disable"
|
||||
ACKIFY_APP_PASSWORD: "${ACKIFY_APP_PASSWORD}"
|
||||
depends_on:
|
||||
ackify-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
command: ["/app/migrate", "up"]
|
||||
entrypoint: []
|
||||
restart: "no"
|
||||
|
||||
ackify-ce:
|
||||
image: btouchard/ackify-ce:latest
|
||||
container_name: ackify-ce
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACKIFY_LOG_LEVEL: "${ACKIFY_LOG_LEVEL}"
|
||||
ACKIFY_LOG_FORMAT: "${ACKIFY_LOG_FORMAT:-classic}"
|
||||
ACKIFY_BASE_URL: "${ACKIFY_BASE_URL}"
|
||||
ACKIFY_ORGANISATION: "${ACKIFY_ORGANISATION}"
|
||||
ACKIFY_ADMIN_EMAILS: "${ACKIFY_ADMIN_EMAILS}"
|
||||
ACKIFY_ONLY_ADMIN_CAN_CREATE: "${ACKIFY_ONLY_ADMIN_CAN_CREATE:-false}"
|
||||
ACKIFY_LISTEN_ADDR: ":8080"
|
||||
ACKIFY_DB_DSN: "postgres://ackify_app:${ACKIFY_APP_PASSWORD}@ackify-db:5432/ackify?sslmode=disable"
|
||||
ACKIFY_OAUTH_PROVIDER: "${ACKIFY_OAUTH_PROVIDER:-}"
|
||||
ACKIFY_OAUTH_CLIENT_ID: "${ACKIFY_OAUTH_CLIENT_ID:-}"
|
||||
ACKIFY_OAUTH_CLIENT_SECRET: "${ACKIFY_OAUTH_CLIENT_SECRET:-}"
|
||||
ACKIFY_OAUTH_AUTH_URL: "${ACKIFY_OAUTH_AUTH_URL:-}"
|
||||
ACKIFY_OAUTH_TOKEN_URL: "${ACKIFY_OAUTH_TOKEN_URL:-}"
|
||||
ACKIFY_OAUTH_USERINFO_URL: "${ACKIFY_OAUTH_USERINFO_URL:-}"
|
||||
ACKIFY_OAUTH_LOGOUT_URL: "${ACKIFY_OAUTH_LOGOUT_URL:-}"
|
||||
ACKIFY_OAUTH_ALLOWED_DOMAIN: "${ACKIFY_OAUTH_ALLOWED_DOMAIN:-}"
|
||||
ACKIFY_OAUTH_SCOPES: "${ACKIFY_OAUTH_SCOPES:-}"
|
||||
ACKIFY_OAUTH_GITLAB_URL: "${ACKIFY_OAUTH_GITLAB_URL:-}"
|
||||
ACKIFY_OAUTH_AUTO_LOGIN: "${ACKIFY_OAUTH_AUTO_LOGIN:-}"
|
||||
ACKIFY_OAUTH_COOKIE_SECRET: "${ACKIFY_OAUTH_COOKIE_SECRET}"
|
||||
ACKIFY_AUTH_OAUTH_ENABLED: "${ACKIFY_AUTH_OAUTH_ENABLED:-}"
|
||||
ACKIFY_AUTH_MAGICLINK_ENABLED: "${ACKIFY_AUTH_MAGICLINK_ENABLED:-}"
|
||||
ACKIFY_AUTH_MAGICLINK_RATE_LIMIT_EMAIL: "${ACKIFY_AUTH_MAGICLINK_RATE_LIMIT_EMAIL:-3}"
|
||||
ACKIFY_AUTH_MAGICLINK_RATE_LIMIT_IP: "${ACKIFY_AUTH_MAGICLINK_RATE_LIMIT_IP:-10}"
|
||||
ACKIFY_AUTH_RATE_LIMIT: "${ACKIFY_AUTH_RATE_LIMIT:-5}"
|
||||
ACKIFY_DOCUMENT_RATE_LIMIT: "${ACKIFY_DOCUMENT_RATE_LIMIT:-10}"
|
||||
ACKIFY_GENERAL_RATE_LIMIT: "${ACKIFY_GENERAL_RATE_LIMIT:-100}"
|
||||
ACKIFY_ED25519_PRIVATE_KEY: "${ACKIFY_ED25519_PRIVATE_KEY}"
|
||||
ACKIFY_MAIL_HOST: "${ACKIFY_MAIL_HOST:-}"
|
||||
ACKIFY_MAIL_PORT: "${ACKIFY_MAIL_PORT:-}"
|
||||
ACKIFY_MAIL_USERNAME: "${ACKIFY_MAIL_USERNAME:-}"
|
||||
ACKIFY_MAIL_PASSWORD: "${ACKIFY_MAIL_PASSWORD:-}"
|
||||
ACKIFY_MAIL_TLS: "${ACKIFY_MAIL_TLS:-}"
|
||||
ACKIFY_MAIL_STARTTLS: "${ACKIFY_MAIL_STARTTLS:-}"
|
||||
ACKIFY_MAIL_INSECURE_SKIP_VERIFY: "${ACKIFY_MAIL_INSECURE_SKIP_VERIFY:-false}"
|
||||
ACKIFY_MAIL_TIMEOUT: "${ACKIFY_MAIL_TIMEOUT:-}"
|
||||
ACKIFY_MAIL_FROM: "${ACKIFY_MAIL_FROM:-}"
|
||||
ACKIFY_MAIL_FROM_NAME: "${ACKIFY_MAIL_FROM_NAME:-}"
|
||||
ACKIFY_MAIL_SUBJECT_PREFIX: "${ACKIFY_MAIL_SUBJECT_PREFIX:-}"
|
||||
ACKIFY_MAIL_TEMPLATE_DIR: "${ACKIFY_MAIL_TEMPLATE_DIR:-}"
|
||||
ACKIFY_MAIL_DEFAULT_LOCALE: "${ACKIFY_MAIL_DEFAULT_LOCALE:-}"
|
||||
ACKIFY_CHECKSUM_MAX_BYTES: "${ACKIFY_CHECKSUM_MAX_BYTES:-10485760}"
|
||||
ACKIFY_CHECKSUM_TIMEOUT_MS: "${ACKIFY_CHECKSUM_TIMEOUT_MS:-5000}"
|
||||
ACKIFY_CHECKSUM_MAX_REDIRECTS: "${ACKIFY_CHECKSUM_MAX_REDIRECTS:-3}"
|
||||
ACKIFY_CHECKSUM_ALLOWED_TYPES: "${ACKIFY_CHECKSUM_ALLOWED_TYPES:-}"
|
||||
ACKIFY_IMPORT_MAX_SIGNERS: "${ACKIFY_IMPORT_MAX_SIGNERS:-500}"
|
||||
ACKIFY_TELEMETRY: "${ACKIFY_TELEMETRY:-false}"
|
||||
ACKIFY_STORAGE_TYPE: "${ACKIFY_STORAGE_TYPE:-}"
|
||||
ACKIFY_STORAGE_LOCAL_PATH: "${ACKIFY_STORAGE_LOCAL_PATH:-/data/documents}"
|
||||
ACKIFY_STORAGE_MAX_SIZE_MB: "${ACKIFY_STORAGE_MAX_SIZE_MB:-50}"
|
||||
ACKIFY_STORAGE_S3_ENDPOINT: "${ACKIFY_STORAGE_S3_ENDPOINT:-}"
|
||||
ACKIFY_STORAGE_S3_BUCKET: "${ACKIFY_STORAGE_S3_BUCKET:-}"
|
||||
ACKIFY_STORAGE_S3_ACCESS_KEY: "${ACKIFY_STORAGE_S3_ACCESS_KEY:-}"
|
||||
ACKIFY_STORAGE_S3_SECRET_KEY: "${ACKIFY_STORAGE_S3_SECRET_KEY:-}"
|
||||
ACKIFY_STORAGE_S3_REGION: "${ACKIFY_STORAGE_S3_REGION:-}"
|
||||
ACKIFY_STORAGE_S3_USE_SSL: "${ACKIFY_STORAGE_S3_USE_SSL:-true}"
|
||||
volumes:
|
||||
- ackify_storage:/data/documents
|
||||
depends_on:
|
||||
ackify-migrate:
|
||||
condition: service_completed_successfully
|
||||
ackify-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
ackify-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: ackify-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- ackify_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
volumes:
|
||||
ackify_data:
|
||||
ackify_storage:
|
||||
@@ -85,15 +85,23 @@ services:
|
||||
condition: service_completed_successfully
|
||||
ackify-db:
|
||||
condition: service_healthy
|
||||
#BEGIN:traefik
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.${APP_NAME}.entrypoints=websecure
|
||||
- traefik.http.routers.${APP_NAME}.rule=Host(`${APP_DNS}`)
|
||||
- traefik.http.routers.${APP_NAME}.tls.certresolver=${TRAEFIK_CERTRESOLVER}
|
||||
- traefik.http.services.${APP_NAME}.loadbalancer.server.port=8080
|
||||
#END:traefik
|
||||
networks:
|
||||
- internal
|
||||
#BEGIN:traefik
|
||||
- traefik
|
||||
#END:traefik
|
||||
#BEGIN:ports
|
||||
ports:
|
||||
- "8080:8080"
|
||||
#END:ports
|
||||
|
||||
ackify-db:
|
||||
image: postgres:16-alpine
|
||||
@@ -115,9 +123,11 @@ services:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
#BEGIN:traefik
|
||||
traefik:
|
||||
name: ${TRAEFIK_NETWORK:-traefik}
|
||||
external: true
|
||||
#END:traefik
|
||||
|
||||
volumes:
|
||||
ackify_data:
|
||||
+717
-258
File diff suppressed because it is too large
Load Diff
@@ -6,31 +6,8 @@
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
sodipodi:docname="icon2.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showguides="true"
|
||||
inkscape:zoom="4.60625"
|
||||
inkscape:cx="73.161465"
|
||||
inkscape:cy="67.408412"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="945"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg3" />
|
||||
<g
|
||||
transform="matrix(3.2560208,-1.1339053,1.1850946,3.1153793,-25.933317,23.475147)"
|
||||
id="g2">
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -216,7 +216,7 @@ watch(readMode, (newMode) => {
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt,.html,.htm,.png,.jpg,.jpeg,.gif,.webp"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.odt,.ods,.txt,.md,.html,.htm,.png,.jpg,.jpeg,.gif,.webp,.svg"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user