{{index .T "unsupported"}}
+diff --git a/cmd/mediashare/main.go b/cmd/mediashare/main.go new file mode 100644 index 0000000..5338ae7 --- /dev/null +++ b/cmd/mediashare/main.go @@ -0,0 +1,127 @@ +// Package main provides the entry point for the MediaShare service. +package main + +import ( + "flag" + "log" + "os" + "strconv" + + "github.com/ritlug/teleirc/internal/mediashare" +) + +func main() { + // Command line flags + port := flag.Int("port", 0, "Port to listen on (overrides MEDIASHARE_PORT)") + apiKey := flag.String("key", "", "API key for uploads (overrides MEDIASHARE_API_KEY)") + baseURL := flag.String("url", "", "Base URL for generated links (overrides MEDIASHARE_BASE_URL)") + storagePath := flag.String("storage", "", "Path to store files (overrides MEDIASHARE_STORAGE_PATH)") + dbPath := flag.String("db", "", "Path to SQLite database (overrides MEDIASHARE_DB_PATH)") + maxSize := flag.Int64("maxsize", 0, "Maximum file size in bytes (overrides MEDIASHARE_MAX_FILE_SIZE)") + retention := flag.Int("retention", 0, "File retention in hours (overrides MEDIASHARE_RETENTION_HOURS)") + serviceName := flag.String("name", "", "Service name for branding (overrides MEDIASHARE_SERVICE_NAME)") + language := flag.String("lang", "", "Language code pl/en (overrides MEDIASHARE_LANGUAGE)") + showList := flag.Bool("showlist", false, "Show file list on main page (overrides MEDIASHARE_SHOW_LIST)") + mainImage := flag.String("mainimg", "", "Path/URL to main page image (overrides MEDIASHARE_MAIN_IMG)") + flag.Parse() + + // Load configuration from environment with flag overrides + config := &mediashare.Config{ + Port: getEnvInt("MEDIASHARE_PORT", 8080), + APIKey: getEnv("MEDIASHARE_API_KEY", ""), + BaseURL: getEnv("MEDIASHARE_BASE_URL", "http://localhost:8080"), + StoragePath: getEnv("MEDIASHARE_STORAGE_PATH", "./uploads"), + DBPath: getEnv("MEDIASHARE_DB_PATH", "./mediashare.db"), + MaxFileSize: getEnvInt64("MEDIASHARE_MAX_FILE_SIZE", 50*1024*1024), // 50MB default + RetentionHours: getEnvInt("MEDIASHARE_RETENTION_HOURS", 72), + ServiceName: getEnv("MEDIASHARE_SERVICE_NAME", "MediaShare"), + Language: getEnv("MEDIASHARE_LANGUAGE", "pl"), + ShowList: getEnvBool("MEDIASHARE_SHOW_LIST", false), + MainImage: getEnv("MEDIASHARE_MAIN_IMG", ""), + } + + // Apply flag overrides + if *port != 0 { + config.Port = *port + } + if *apiKey != "" { + config.APIKey = *apiKey + } + if *baseURL != "" { + config.BaseURL = *baseURL + } + if *storagePath != "" { + config.StoragePath = *storagePath + } + if *dbPath != "" { + config.DBPath = *dbPath + } + if *maxSize != 0 { + config.MaxFileSize = *maxSize + } + if *retention != 0 { + config.RetentionHours = *retention + } + if *serviceName != "" { + config.ServiceName = *serviceName + } + if *language != "" { + config.Language = *language + } + if *showList { + config.ShowList = true + } + if *mainImage != "" { + config.MainImage = *mainImage + } + + // Ensure storage directory exists + if err := os.MkdirAll(config.StoragePath, 0755); err != nil { + log.Fatalf("Failed to create storage directory: %v", err) + } + + // Start server + server, err := mediashare.NewServer(config) + if err != nil { + log.Fatalf("Failed to create server: %v", err) + } + defer server.Close() + + if err := server.Start(); err != nil { + log.Fatalf("Server error: %v", err) + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + } + return defaultValue +} + +func getEnvInt64(key string, defaultValue int64) int64 { + if value := os.Getenv(key); value != "" { + if intVal, err := strconv.ParseInt(value, 10, 64); err == nil { + return intVal + } + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if boolVal, err := strconv.ParseBool(value); err == nil { + return boolVal + } + } + return defaultValue +} diff --git a/cmd/teleirc.go b/cmd/teleirc.go index fa8921c..e1e2353 100644 --- a/cmd/teleirc.go +++ b/cmd/teleirc.go @@ -43,7 +43,7 @@ func main() { signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM) var tgapi *tgbotapi.BotAPI - tgClient := tg.NewClient(&settings.Telegram, &settings.IRC, &settings.Imgur, tgapi, logger) + tgClient := tg.NewClient(&settings.Telegram, &settings.IRC, &settings.Imgur, &settings.MediaShare, tgapi, logger) tgChan := make(chan error) ircClient := irc.NewClient(&settings.IRC, &settings.Telegram, logger) diff --git a/deployments/container/README.md b/deployments/container/README.md index a4e1d87..b69e7f1 100644 --- a/deployments/container/README.md +++ b/deployments/container/README.md @@ -4,3 +4,20 @@ Using TeleIRC with Docker The files included here are examples for you to use. For more information on using them, [read the documentation](https://docs.teleirc.com/en/latest/deploy-teleirc/#docker). Before using them, copy files you intend to use to the root directory of the repository. + +## Included Dockerfiles + +- `Dockerfile` - TeleIRC main bot +- `mediashare.Dockerfile` - MediaShare media hosting service (optional) + +## Using MediaShare + +MediaShare is an optional service for hosting Telegram media files. See [MediaShare documentation](../../docs/user/mediashare.md) for details. + +To run both TeleIRC and MediaShare together, use the docker-compose example: + +```bash +cp docker-compose.yml.example docker-compose.yml +# Edit .env to configure both services +docker-compose up -d +``` diff --git a/deployments/container/docker-compose.yml.example b/deployments/container/docker-compose.yml.example index 59ce7db..9c49237 100644 --- a/deployments/container/docker-compose.yml.example +++ b/deployments/container/docker-compose.yml.example @@ -10,3 +10,23 @@ services: dockerfile: ./deployments/container/Dockerfile env_file: ../../.env user: teleirc + depends_on: + - mediashare + + mediashare: + build: + context: ../../ + dockerfile: ./deployments/container/mediashare.Dockerfile + ports: + - "8090:8090" + volumes: + - mediashare-data:/app/data + environment: + - MEDIASHARE_API_KEY=${MEDIASHARE_API_KEY} + - MEDIASHARE_BASE_URL=${MEDIASHARE_BASE_URL:-http://localhost:8090} + - MEDIASHARE_RETENTION_HOURS=${MEDIASHARE_RETENTION_HOURS:-168} + - MEDIASHARE_LANG=${MEDIASHARE_LANG:-en} + - MEDIASHARE_SERVICE_NAME=${MEDIASHARE_SERVICE_NAME:-MediaShare} + +volumes: + mediashare-data: diff --git a/deployments/container/mediashare.Dockerfile b/deployments/container/mediashare.Dockerfile new file mode 100644 index 0000000..abc8414 --- /dev/null +++ b/deployments/container/mediashare.Dockerfile @@ -0,0 +1,36 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . ./ + +RUN go build -o mediashare ./cmd/mediashare + +# Use a minimal base image to run the binary +FROM alpine:latest + +RUN adduser -D mediashare-user && \ + mkdir -p /app/data/uploads && \ + chown -R mediashare-user:mediashare-user /app + +USER mediashare-user + +COPY --from=builder /app/mediashare /app/mediashare + +WORKDIR /app + +# Default environment variables +ENV MEDIASHARE_PORT=8090 +ENV MEDIASHARE_STORAGE_PATH=/app/data/uploads +ENV MEDIASHARE_DB_PATH=/app/data/mediashare.db +ENV MEDIASHARE_RETENTION_HOURS=168 +ENV MEDIASHARE_LANG=en + +EXPOSE 8090 + +VOLUME ["/app/data"] + +ENTRYPOINT ["./mediashare"] diff --git a/docs/user/mediashare.md b/docs/user/mediashare.md new file mode 100644 index 0000000..cee7f1d --- /dev/null +++ b/docs/user/mediashare.md @@ -0,0 +1,201 @@ +# MediaShare Service + +MediaShare is an optional companion service for TeleIRC that enables sharing of media files (photos, videos, voice messages) from Telegram to IRC. Since IRC doesn't support inline media, MediaShare provides a self-hosted solution for storing and serving these files via web links. + +## Contents + +1. [Overview](#overview) +2. [Features](#features) +3. [Configuration](#configuration) + 1. [TeleIRC Configuration](#teleirc-configuration) + 2. [MediaShare Configuration](#mediashare-configuration) +4. [Deployment](#deployment) + 1. [Run with Docker](#run-with-docker) + 2. [Run as Binary](#run-as-binary) + 3. [Run with Systemd](#run-with-systemd) +5. [How It Works](#how-it-works) + +## Overview + +When a Telegram user shares a photo, video, or voice message, TeleIRC uploads it to the MediaShare service. MediaShare stores the file and returns a short URL that is posted to IRC. IRC users can click the link to view the media in their browser with a modern, responsive player. + +**Key benefits:** +- Self-hosted - you control your data +- No external service dependencies (unlike Imgur) +- Supports video, audio, images, and other files +- Modern web player with dark/light mode +- Automatic cleanup of old files +- Bilingual support (English/Polish) + +## Features + +- **Media Player**: Modern responsive web player for video and audio +- **Image Viewer**: Lightbox-style image viewing with thumbnails +- **File List**: Browse recent uploads at the root URL +- **Auto-cleanup**: Configurable retention period for automatic file deletion +- **Internationalization**: English and Polish language support +- **Dark Mode**: Automatic dark/light mode based on system preference +- **API Key**: Optional authentication for uploads +- **SQLite Database**: Lightweight metadata storage + +## Configuration + +### TeleIRC Configuration + +Add these settings to your TeleIRC environment file: + +```bash +# Enable MediaShare integration +MEDIASHARE_ENABLED=true + +# MediaShare server URL (where MediaShare is running) +MEDIASHARE_URL=http://localhost:8090 + +# API key for uploads (must match MediaShare's API key) +MEDIASHARE_API_KEY=your-secret-api-key + +# Maximum file size in bytes (default: 50MB) +MEDIASHARE_MAX_SIZE=52428800 + +# Language for IRC messages (en or pl) +MEDIASHARE_LANG=en +``` + +### MediaShare Configuration + +MediaShare uses these environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `MEDIASHARE_PORT` | `8090` | HTTP server port | +| `MEDIASHARE_API_KEY` | (empty) | API key for upload authentication. If empty, no authentication required | +| `MEDIASHARE_BASE_URL` | `http://localhost:8090` | Public URL for generating links | +| `MEDIASHARE_STORAGE_PATH` | `./uploads` | Directory for storing uploaded files | +| `MEDIASHARE_DB_PATH` | `./mediashare.db` | SQLite database file path | +| `MEDIASHARE_MAX_FILE_SIZE` | `52428800` | Maximum file size in bytes (50MB) | +| `MEDIASHARE_RETENTION_HOURS` | `168` | Hours to keep files (168 = 7 days). Set to 0 to disable cleanup | +| `MEDIASHARE_SERVICE_NAME` | `MediaShare` | Service name shown in web UI | +| `MEDIASHARE_LANG` | `en` | Interface language (`en` or `pl`) | + +## Deployment + +### Run with Docker + +Build and run MediaShare with Docker: + +```bash +# Build the image +docker build -t mediashare -f deployments/container/mediashare.Dockerfile . + +# Run the container +docker run -d \ + --name mediashare \ + -p 8090:8090 \ + -v mediashare-data:/app/data \ + -e MEDIASHARE_API_KEY=your-secret-key \ + -e MEDIASHARE_BASE_URL=https://media.example.com \ + -e MEDIASHARE_STORAGE_PATH=/app/data/uploads \ + -e MEDIASHARE_DB_PATH=/app/data/mediashare.db \ + mediashare +``` + +### Run as Binary + +Build MediaShare from source: + +```bash +# Build +go build -o mediashare ./cmd/mediashare + +# Run +MEDIASHARE_API_KEY=your-secret-key \ +MEDIASHARE_BASE_URL=https://media.example.com \ +./mediashare +``` + +### Run with Systemd + +Create `/etc/systemd/system/mediashare.service`: + +```ini +[Unit] +Description=MediaShare Service +After=network.target + +[Service] +Type=simple +User=mediashare +WorkingDirectory=/opt/mediashare +ExecStart=/opt/mediashare/mediashare +Restart=always +RestartSec=5 + +# Environment +Environment=MEDIASHARE_PORT=8090 +Environment=MEDIASHARE_API_KEY=your-secret-key +Environment=MEDIASHARE_BASE_URL=https://media.example.com +Environment=MEDIASHARE_STORAGE_PATH=/opt/mediashare/uploads +Environment=MEDIASHARE_DB_PATH=/opt/mediashare/data/mediashare.db +Environment=MEDIASHARE_RETENTION_HOURS=168 +Environment=MEDIASHARE_LANG=en + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable mediashare +sudo systemctl start mediashare +``` + +## How It Works + +### Upload Flow + +1. Telegram user sends a photo/video/voice message +2. TeleIRC receives the message and downloads the file from Telegram +3. TeleIRC uploads the file to MediaShare via HTTP POST to `/upload` +4. MediaShare stores the file and returns a JSON response with the URL +5. TeleIRC posts the formatted message to IRC with the media URL + +### URL Structure + +MediaShare generates short, clean URLs: + +- `/{id}` - View page with media player +- `/r/{id}` - Raw file (direct download/embed) +- `/` - File list (recent uploads) +- `/health` - Health check endpoint + +Where `{id}` is a 5-character alphanumeric identifier (e.g., `Ab3xK`). + +### File Storage + +Files are stored in a date-based directory structure: + +``` +uploads/ +├── 2024/ +│ └── 12/ +│ └── 07/ +│ ├── Ab3xK.mp4 +│ └── xY9pQ.jpg +``` + +### Cleanup + +The cleanup worker runs every hour and removes files that: +- Haven't been opened within the retention period +- Or were uploaded but never opened within the retention period + +This ensures disk space is managed automatically while keeping frequently accessed files available. + +### Security + +- **API Key**: Use `MEDIASHARE_API_KEY` to restrict uploads to authorized clients +- **CORS**: Enabled by default for cross-origin requests +- **Content-Type**: Properly set based on file type for safe browser handling +- **Path Traversal**: File IDs are randomly generated, preventing path traversal attacks diff --git a/env.example b/env.example index c9282e7..d95c4d6 100644 --- a/env.example +++ b/env.example @@ -87,3 +87,60 @@ IMGUR_CLIENT_ID=7d6b00b87043f58 IMGUR_CLIENT_SECRET="" IMGUR_REFRESH_TOKEN="" IMGUR_ALBUM_HASH="" + + +############################################################################### +# # +# MediaShare integration settings # +# (For TeleIRC to upload media) # +# # +############################################################################### + +# Enable MediaShare integration (will fall back to Imgur for images if disabled) +MEDIASHARE_ENABLED=false + +# MediaShare service endpoint URL (e.g., https://media.example.com) +MEDIASHARE_ENDPOINT="" + +# API key for MediaShare authentication (client side) +MEDIASHARE_API_KEY="" + + +############################################################################### +# # +# MediaShare Server configuration # +# (For running MediaShare standalone) # +# # +############################################################################### + +# Port for MediaShare server (for reverse proxy setup) +MEDIASHARE_PORT=8080 + +# Base URL for generated links (your public domain) +MEDIASHARE_BASE_URL=https://media.example.com + +# Path to store uploaded files +MEDIASHARE_STORAGE_PATH=./uploads + +# Path to SQLite database file +MEDIASHARE_DB_PATH=./mediashare.db + +# Maximum file size in bytes (default: 50MB) +MEDIASHARE_MAX_FILE_SIZE=52428800 + +# File retention in hours - files not opened for this duration will be deleted +# Set to 0 to disable auto-cleanup +MEDIASHARE_RETENTION_HOURS=72 + +# Service name used in HTML meta tags and branding +MEDIASHARE_SERVICE_NAME=MediaShare + +# Language for UI and IRC messages (pl/en) +MEDIASHARE_LANGUAGE=pl + +# Show file list on main page (true/false). Disabled by default for privacy. +MEDIASHARE_SHOW_LIST=false + +# Path or URL to image shown on main page. If set, overrides ShowList. +# Can be a local path (e.g., /images/logo.png) or external URL. +MEDIASHARE_MAIN_IMG= diff --git a/go.mod b/go.mod index 49b6672..a4aa454 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,35 @@ module github.com/ritlug/teleirc -go 1.14 +go 1.24.0 require ( github.com/caarlos0/env/v6 v6.0.0 - github.com/go-playground/locales v0.12.1 // indirect - github.com/go-playground/universal-translator v0.16.0 // indirect github.com/go-playground/validator v9.29.1+incompatible github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/golang/mock v1.4.3 github.com/joho/godotenv v1.3.0 github.com/kyokomi/emoji v2.1.0+incompatible - github.com/leodido/go-urn v1.1.0 // indirect github.com/lrstanley/girc v0.0.0-20190801035559-4fc93959e1a7 github.com/stretchr/testify v1.3.0 + modernc.org/sqlite v1.40.1 +) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-playground/locales v0.12.1 // indirect + github.com/go-playground/universal-translator v0.16.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/leodido/go-urn v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index ab36660..61a932b 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/caarlos0/env/v6 v6.0.0 h1:NZt6FAoB8ieKO5lEwRdwCzYxWFx7ZYF2R7UcoyaWtyc github.com/caarlos0/env/v6 v6.0.0/go.mod h1:+wdyOmtjoZIW2GJOc2OYa5NoOFuWD/bIpWqm30NgtRk= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= @@ -12,6 +14,10 @@ github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaEL github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/kyokomi/emoji v2.1.0+incompatible h1:+DYU2RgpI6OHG4oQkM5KlqD3Wd3UPEsX8jamTo1Mp6o= @@ -20,22 +26,64 @@ github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/lrstanley/girc v0.0.0-20190801035559-4fc93959e1a7 h1:BS9tqL0OCiOGuy/CYYk2gc33fxqaqh5/rhqMKu4tcYA= github.com/lrstanley/girc v0.0.0-20190801035559-4fc93959e1a7/go.mod h1:liX5MxHPrwgHaKowoLkYGwbXfYABh1jbZ6FpElbGF1I= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262 h1:qsl9y/CJx34tuA7QCPNp86JNJe4spst6Ff8MjvPUdPg= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/config.go b/internal/config.go index 4d22cc9..05751a9 100644 --- a/internal/config.go +++ b/internal/config.go @@ -76,11 +76,19 @@ type ImgurSettings struct { ImgurAlbumHash string `env:"IMGUR_ALBUM_HASH" envDefault:""` } +// MediaShareSettings includes settings for the MediaShare file hosting service +type MediaShareSettings struct { + Enabled bool `env:"MEDIASHARE_ENABLED" envDefault:"false"` + Endpoint string `env:"MEDIASHARE_ENDPOINT" envDefault:""` + APIKey string `env:"MEDIASHARE_API_KEY" envDefault:""` +} + // Settings includes all user-configurable settings for TeleIRC type Settings struct { - IRC IRCSettings - Telegram TelegramSettings - Imgur ImgurSettings + IRC IRCSettings + Telegram TelegramSettings + Imgur ImgurSettings + MediaShare MediaShareSettings } func validateEmptyString(fl validator.FieldLevel) bool { diff --git a/internal/handlers/telegram/handler.go b/internal/handlers/telegram/handler.go index 021f49d..2c7b518 100644 --- a/internal/handlers/telegram/handler.go +++ b/internal/handlers/telegram/handler.go @@ -46,6 +46,12 @@ func updateHandler(tg *Client, updates tgbotapi.UpdatesChannel) { case u.Message.Location != nil: tg.logger.LogDebug("locationHandler triggered") locationHandler(tg, u.Message) + case u.Message.Video != nil: + tg.logger.LogDebug("videoHandler triggered") + videoHandler(tg, u.Message) + case u.Message.Voice != nil: + tg.logger.LogDebug("voiceHandler triggered") + voiceHandler(tg, u.Message) default: tg.logger.LogWarning("Triggered, but message type is currently unsupported") tg.logger.LogWarning("Unhandled Update:", u) @@ -155,20 +161,25 @@ func stickerHandler(tg *Client, u tgbotapi.Update) { } /* -photoHandler handles the Message.Photo Telegram object. Only acknowledges Photo -exists, and sends notification to IRC +photoHandler handles the Message.Photo Telegram object. Uploads photo +and sends notification with link to IRC. */ func photoHandler(tg *Client, u tgbotapi.Update) { - link := uploadImage(tg, u) username := GetUsername(tg.IRCSettings.ShowZWSP, u.Message.From) + + // Get the largest photo size + photo := (*u.Message.Photo)[len(*u.Message.Photo)-1] + + // Try to upload + link := uploadPhoto(tg, photo.FileID, username) + caption := u.Message.Caption if caption == "" { caption = "No caption provided." } - // TeleIRC can fail to upload to Imgur if link == "" { - tg.logger.LogError("Failed imgur photo upload for", username) + tg.logger.LogError("Failed photo upload for", username) } else { formatted := "'" + caption + "' uploaded by " + username + ": " + link tg.sendToIrc(formatted) @@ -217,3 +228,41 @@ func locationHandler(tg *Client, u *tgbotapi.Message) { tg.sendToIrc(formatted) } + +/* +videoHandler receives a video object from Telegram, and sends +a notification to IRC with optional upload link. +*/ +func videoHandler(tg *Client, u *tgbotapi.Message) { + username := GetUsername(tg.IRCSettings.ShowZWSP, u.From) + + // Try to upload and get link + var link string + if u.Video != nil { + filename := "video.mp4" + if u.Video.MimeType == "video/webm" { + filename = "video.webm" + } + link = uploadVideo(tg, u.Video.FileID, filename, username) + } + + formatted := formatMediaMessage(username, "video", u.Caption, link) + tg.sendToIrc(formatted) +} + +/* +voiceHandler receives a voice message object from Telegram, and sends +a notification to IRC with optional upload link. +*/ +func voiceHandler(tg *Client, u *tgbotapi.Message) { + username := GetUsername(tg.IRCSettings.ShowZWSP, u.From) + + // Try to upload and get link + var link string + if u.Voice != nil { + link = uploadVoice(tg, u.Voice.FileID, username) + } + + formatted := formatMediaMessage(username, "voice message", u.Caption, link) + tg.sendToIrc(formatted) +} diff --git a/internal/handlers/telegram/handler_test.go b/internal/handlers/telegram/handler_test.go index e356c5b..4b3ecbb 100644 --- a/internal/handlers/telegram/handler_test.go +++ b/internal/handlers/telegram/handler_test.go @@ -1012,3 +1012,139 @@ func TestLocationHandlerWithLocationDisabled(t *testing.T) { locationHandler(clientObj, &messageObj) } + +/* +TestVideoHandlerWithCaption tests video handler with a caption +*/ +func TestVideoHandlerWithCaption(t *testing.T) { + testUser := &tgbotapi.User{ + ID: 1, + UserName: "test", + FirstName: "testing", + LastName: "123", + } + + correct := "test shared a video on Telegram with caption: 'Check this out!'." + + messageObj := tgbotapi.Message{ + From: testUser, + Video: &tgbotapi.Video{ + FileID: "AgADBAAD...", + Width: 1080, + Height: 1920, + Duration: 60, + }, + Caption: "Check this out!", + } + clientObj := &Client{ + IRCSettings: &internal.IRCSettings{ + ShowZWSP: false, + }, + sendToIrc: func(s string) { + assert.Equal(t, correct, s) + }, + } + + videoHandler(clientObj, &messageObj) +} + +/* +TestVideoHandlerWithoutCaption tests video handler without a caption +*/ +func TestVideoHandlerWithoutCaption(t *testing.T) { + testUser := &tgbotapi.User{ + ID: 1, + UserName: "test", + FirstName: "testing", + LastName: "123", + } + + correct := "test shared a video on Telegram." + + messageObj := tgbotapi.Message{ + From: testUser, + Video: &tgbotapi.Video{ + FileID: "AgADBAAD...", + Width: 1080, + Height: 1920, + Duration: 60, + }, + Caption: "", + } + clientObj := &Client{ + IRCSettings: &internal.IRCSettings{ + ShowZWSP: false, + }, + sendToIrc: func(s string) { + assert.Equal(t, correct, s) + }, + } + + videoHandler(clientObj, &messageObj) +} + +/* +TestVoiceHandlerWithCaption tests voice handler with a caption +*/ +func TestVoiceHandlerWithCaption(t *testing.T) { + testUser := &tgbotapi.User{ + ID: 1, + UserName: "test", + FirstName: "testing", + LastName: "123", + } + + correct := "test shared a voice message on Telegram with caption: 'Listen to this!'." + + messageObj := tgbotapi.Message{ + From: testUser, + Voice: &tgbotapi.Voice{ + FileID: "AwADBAAD...", + Duration: 30, + }, + Caption: "Listen to this!", + } + clientObj := &Client{ + IRCSettings: &internal.IRCSettings{ + ShowZWSP: false, + }, + sendToIrc: func(s string) { + assert.Equal(t, correct, s) + }, + } + + voiceHandler(clientObj, &messageObj) +} + +/* +TestVoiceHandlerWithoutCaption tests voice handler without a caption +*/ +func TestVoiceHandlerWithoutCaption(t *testing.T) { + testUser := &tgbotapi.User{ + ID: 1, + UserName: "test", + FirstName: "testing", + LastName: "123", + } + + correct := "test shared a voice message on Telegram." + + messageObj := tgbotapi.Message{ + From: testUser, + Voice: &tgbotapi.Voice{ + FileID: "AwADBAAD...", + Duration: 30, + }, + Caption: "", + } + clientObj := &Client{ + IRCSettings: &internal.IRCSettings{ + ShowZWSP: false, + }, + sendToIrc: func(s string) { + assert.Equal(t, correct, s) + }, + } + + voiceHandler(clientObj, &messageObj) +} diff --git a/internal/handlers/telegram/telegram.go b/internal/handlers/telegram/telegram.go index 1479f86..00102e4 100644 --- a/internal/handlers/telegram/telegram.go +++ b/internal/handlers/telegram/telegram.go @@ -11,20 +11,21 @@ Client contains information for the Telegram bridge, including the TelegramSettings needed to run the bot */ type Client struct { - api *tgbotapi.BotAPI - Settings *internal.TelegramSettings - IRCSettings *internal.IRCSettings - ImgurSettings *internal.ImgurSettings - logger internal.DebugLogger - sendToIrc func(string) + api *tgbotapi.BotAPI + Settings *internal.TelegramSettings + IRCSettings *internal.IRCSettings + ImgurSettings *internal.ImgurSettings + MediaShareSettings *internal.MediaShareSettings + logger internal.DebugLogger + sendToIrc func(string) } /* NewClient creates a new Telegram bot client */ -func NewClient(settings *internal.TelegramSettings, ircsettings *internal.IRCSettings, imgur *internal.ImgurSettings, tgapi *tgbotapi.BotAPI, logger internal.DebugLogger) *Client { +func NewClient(settings *internal.TelegramSettings, ircsettings *internal.IRCSettings, imgur *internal.ImgurSettings, mediashare *internal.MediaShareSettings, tgapi *tgbotapi.BotAPI, logger internal.DebugLogger) *Client { logger.LogInfo("Creating new Telegram bot client...") - return &Client{api: tgapi, Settings: settings, IRCSettings: ircsettings, ImgurSettings: imgur, logger: logger} + return &Client{api: tgapi, Settings: settings, IRCSettings: ircsettings, ImgurSettings: imgur, MediaShareSettings: mediashare, logger: logger} } /* diff --git a/internal/handlers/telegram/telegram_test.go b/internal/handlers/telegram/telegram_test.go index 4e60737..d00a3af 100644 --- a/internal/handlers/telegram/telegram_test.go +++ b/internal/handlers/telegram/telegram_test.go @@ -29,7 +29,7 @@ func TestNewClientBasic(t *testing.T) { DebugLevel: false, } var tgapi *tgbotapi.BotAPI - client := NewClient(tgRequiredSettings, nil, imgurSettings, tgapi, logger) + client := NewClient(tgRequiredSettings, nil, imgurSettings, nil, tgapi, logger) assert.Equal(t, client.Settings, tgExpectedSettings, "Basic client settings should be properly set") } @@ -59,7 +59,7 @@ func TestNewClientFull(t *testing.T) { DebugLevel: false, } var tgapi *tgbotapi.BotAPI - client := NewClient(tgSettings, nil, imgurSettings, tgapi, logger) + client := NewClient(tgSettings, nil, imgurSettings, nil, tgapi, logger) assert.Equal(t, client.Settings, tgSettings, "All client settings should be properly set") assert.NotEqual(t, client.Settings, tgDefaultSettings, "tgSettings should override defaults") } diff --git a/internal/handlers/telegram/upload.go b/internal/handlers/telegram/upload.go new file mode 100644 index 0000000..265220c --- /dev/null +++ b/internal/handlers/telegram/upload.go @@ -0,0 +1,201 @@ +package telegram + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "path/filepath" + "strings" +) + +// MediaShareResponse represents the response from MediaShare upload endpoint. +type MediaShareResponse struct { + Success bool `json:"success"` + URL string `json:"url"` + RawURL string `json:"raw_url"` + ID string `json:"id"` + Filename string `json:"filename"` + Error string `json:"error,omitempty"` +} + +// uploadToMediaShare uploads a file from a URL to MediaShare service. +func uploadToMediaShare(tg *Client, fileURL string, filename string, username string) string { + if tg.MediaShareSettings == nil || !tg.MediaShareSettings.Enabled { + return "" + } + + if tg.MediaShareSettings.Endpoint == "" { + tg.logger.LogError("MediaShare endpoint not configured") + return "" + } + + // Download file from Telegram + resp, err := http.Get(fileURL) + if err != nil { + tg.logger.LogError("Failed to download file from Telegram:", err) + return "" + } + defer resp.Body.Close() + + // Read the file content + fileContent, err := io.ReadAll(resp.Body) + if err != nil { + tg.logger.LogError("Failed to read file content:", err) + return "" + } + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("file", filename) + if err != nil { + tg.logger.LogError("Failed to create form file:", err) + return "" + } + + if _, err := part.Write(fileContent); err != nil { + tg.logger.LogError("Failed to write file to form:", err) + return "" + } + + // Add username field + if username != "" { + if err := writer.WriteField("username", username); err != nil { + tg.logger.LogError("Failed to write username field:", err) + return "" + } + } + + if err := writer.Close(); err != nil { + tg.logger.LogError("Failed to close multipart writer:", err) + return "" + } + + // Send to MediaShare + endpoint := strings.TrimSuffix(tg.MediaShareSettings.Endpoint, "/") + "/upload" + req, err := http.NewRequest("POST", endpoint, body) + if err != nil { + tg.logger.LogError("Failed to create request:", err) + return "" + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + if tg.MediaShareSettings.APIKey != "" { + req.Header.Set("X-API-Key", tg.MediaShareSettings.APIKey) + } + + client := &http.Client{} + uploadResp, err := client.Do(req) + if err != nil { + tg.logger.LogError("Failed to upload to MediaShare:", err) + return "" + } + defer uploadResp.Body.Close() + + // Parse response + var result MediaShareResponse + if err := json.NewDecoder(uploadResp.Body).Decode(&result); err != nil { + tg.logger.LogError("Failed to parse MediaShare response:", err) + return "" + } + + if !result.Success { + tg.logger.LogError("MediaShare upload failed:", result.Error) + return "" + } + + tg.logger.LogDebug("Uploaded to MediaShare:", result.URL) + return result.URL +} + +// uploadFile uploads a file to MediaShare (or falls back to Imgur for images). +// Returns the URL of the uploaded file. +func uploadFile(tg *Client, fileID string, filename string, username string) string { + // Check if API client is available + if tg.api == nil { + return "" + } + + // Get Telegram file URL + fileURL, err := tg.api.GetFileDirectURL(fileID) + if err != nil { + tg.logger.LogError("Failed to get Telegram file URL:", err) + return "" + } + + // Try MediaShare first if enabled + if tg.MediaShareSettings != nil && tg.MediaShareSettings.Enabled { + if url := uploadToMediaShare(tg, fileURL, filename, username); url != "" { + return url + } + } + + // Fall back to Imgur for images only + ext := strings.ToLower(filepath.Ext(filename)) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" { + return getImgurLink(tg, fileURL) + } + + return "" +} + +// uploadVideo uploads a video file and returns the URL. +func uploadVideo(tg *Client, fileID string, filename string, username string) string { + if filename == "" { + filename = "video.mp4" + } + return uploadFile(tg, fileID, filename, username) +} + +// uploadVoice uploads a voice message and returns the URL. +func uploadVoice(tg *Client, fileID string, username string) string { + return uploadFile(tg, fileID, "voice.ogg", username) +} + +// uploadPhoto uploads a photo and returns the URL. +// Uses the existing Imgur integration as fallback. +func uploadPhoto(tg *Client, fileID string, username string) string { + // Check if API client is available + if tg.api == nil { + return "" + } + + // Get Telegram file URL + fileURL, err := tg.api.GetFileDirectURL(fileID) + if err != nil { + tg.logger.LogError("Failed to get Telegram photo URL:", err) + return "" + } + + // Try MediaShare first if enabled + if tg.MediaShareSettings != nil && tg.MediaShareSettings.Enabled { + if url := uploadToMediaShare(tg, fileURL, "photo.jpg", username); url != "" { + return url + } + } + + // Fall back to Imgur + return getImgurLink(tg, fileURL) +} + +// formatMediaMessage formats a message for media sharing. +func formatMediaMessage(username string, mediaType string, caption string, url string) string { + if url == "" { + formatted := username + " shared a " + mediaType + " on Telegram" + if caption != "" { + formatted += " with caption: '" + caption + "'." + } else { + formatted += "." + } + return formatted + } + + if caption != "" { + return fmt.Sprintf("'%s' %s by %s: %s", caption, mediaType, username, url) + } + return fmt.Sprintf("%s shared by %s: %s", mediaType, username, url) +} diff --git a/internal/mediashare/database.go b/internal/mediashare/database.go new file mode 100644 index 0000000..4f9466f --- /dev/null +++ b/internal/mediashare/database.go @@ -0,0 +1,277 @@ +package mediashare + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// FileRecord represents a file entry in the database. +type FileRecord struct { + ID string + Filename string + ContentType string + Size int64 + Path string + Username string + UploadedAt time.Time + LastOpenedAt *time.Time + OpenCount int +} + +// Database wraps SQLite operations for MediaShare. +type Database struct { + db *sql.DB +} + +// NewDatabase creates a new database connection and initializes schema. +func NewDatabase(dbPath string) (*Database, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Enable WAL mode for better concurrent access + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + db.Close() + return nil, fmt.Errorf("failed to enable WAL mode: %w", err) + } + + database := &Database{db: db} + if err := database.initSchema(); err != nil { + db.Close() + return nil, err + } + + return database, nil +} + +// initSchema creates the files table if it doesn't exist. +func (d *Database) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size INTEGER NOT NULL, + path TEXT NOT NULL, + username TEXT, + uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_opened_at DATETIME, + open_count INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_last_opened ON files(last_opened_at); + CREATE INDEX IF NOT EXISTS idx_uploaded_at ON files(uploaded_at); + ` + _, err := d.db.Exec(schema) + if err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + return nil +} + +// Insert adds a new file record to the database. +func (d *Database) Insert(record *FileRecord) error { + query := ` + INSERT INTO files (id, filename, content_type, size, path, username, uploaded_at, last_opened_at, open_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + _, err := d.db.Exec(query, + record.ID, + record.Filename, + record.ContentType, + record.Size, + record.Path, + record.Username, + record.UploadedAt, + record.LastOpenedAt, + record.OpenCount, + ) + if err != nil { + return fmt.Errorf("failed to insert file record: %w", err) + } + return nil +} + +// Get retrieves a file record by ID. +func (d *Database) Get(id string) (*FileRecord, error) { + query := ` + SELECT id, filename, content_type, size, path, username, uploaded_at, last_opened_at, open_count + FROM files WHERE id = ? + ` + row := d.db.QueryRow(query, id) + + var record FileRecord + var lastOpenedAt sql.NullTime + var username sql.NullString + + err := row.Scan( + &record.ID, + &record.Filename, + &record.ContentType, + &record.Size, + &record.Path, + &username, + &record.UploadedAt, + &lastOpenedAt, + &record.OpenCount, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get file record: %w", err) + } + + if lastOpenedAt.Valid { + record.LastOpenedAt = &lastOpenedAt.Time + } + if username.Valid { + record.Username = username.String + } + + return &record, nil +} + +// UpdateLastOpened updates the last_opened_at timestamp and increments open_count. +func (d *Database) UpdateLastOpened(id string) error { + query := ` + UPDATE files + SET last_opened_at = ?, open_count = open_count + 1 + WHERE id = ? + ` + _, err := d.db.Exec(query, time.Now(), id) + if err != nil { + return fmt.Errorf("failed to update last opened: %w", err) + } + return nil +} + +// GetFilesNotOpenedSince returns files that haven't been opened since the cutoff time. +// If a file was never opened, it uses uploaded_at for comparison. +func (d *Database) GetFilesNotOpenedSince(cutoff time.Time) ([]*FileRecord, error) { + query := ` + SELECT id, filename, content_type, size, path, username, uploaded_at, last_opened_at, open_count + FROM files + WHERE (last_opened_at IS NOT NULL AND last_opened_at < ?) + OR (last_opened_at IS NULL AND uploaded_at < ?) + ` + rows, err := d.db.Query(query, cutoff, cutoff) + if err != nil { + return nil, fmt.Errorf("failed to query expired files: %w", err) + } + defer rows.Close() + + var records []*FileRecord + for rows.Next() { + var record FileRecord + var lastOpenedAt sql.NullTime + var username sql.NullString + + err := rows.Scan( + &record.ID, + &record.Filename, + &record.ContentType, + &record.Size, + &record.Path, + &username, + &record.UploadedAt, + &lastOpenedAt, + &record.OpenCount, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan file record: %w", err) + } + + if lastOpenedAt.Valid { + record.LastOpenedAt = &lastOpenedAt.Time + } + if username.Valid { + record.Username = username.String + } + + records = append(records, &record) + } + + return records, nil +} + +// Delete removes a file record by ID. +func (d *Database) Delete(id string) error { + query := `DELETE FROM files WHERE id = ?` + _, err := d.db.Exec(query, id) + if err != nil { + return fmt.Errorf("failed to delete file record: %w", err) + } + return nil +} + +// Exists checks if a file with given ID exists in the database. +func (d *Database) Exists(id string) (bool, error) { + query := `SELECT 1 FROM files WHERE id = ? LIMIT 1` + row := d.db.QueryRow(query, id) + var exists int + err := row.Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to check existence: %w", err) + } + return true, nil +} + +// GetRecentFiles returns the most recent files, ordered by upload date descending. +func (d *Database) GetRecentFiles(limit int) ([]*FileRecord, error) { + query := ` + SELECT id, filename, content_type, size, path, username, uploaded_at, last_opened_at, open_count + FROM files + ORDER BY uploaded_at DESC + LIMIT ? + ` + rows, err := d.db.Query(query, limit) + if err != nil { + return nil, fmt.Errorf("failed to query recent files: %w", err) + } + defer rows.Close() + + var records []*FileRecord + for rows.Next() { + var record FileRecord + var lastOpenedAt sql.NullTime + var username sql.NullString + + err := rows.Scan( + &record.ID, + &record.Filename, + &record.ContentType, + &record.Size, + &record.Path, + &username, + &record.UploadedAt, + &lastOpenedAt, + &record.OpenCount, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan file record: %w", err) + } + + if lastOpenedAt.Valid { + record.LastOpenedAt = &lastOpenedAt.Time + } + if username.Valid { + record.Username = username.String + } + + records = append(records, &record) + } + + return records, nil +} + +// Close closes the database connection. +func (d *Database) Close() error { + return d.db.Close() +} diff --git a/internal/mediashare/i18n.go b/internal/mediashare/i18n.go new file mode 100644 index 0000000..cc03a47 --- /dev/null +++ b/internal/mediashare/i18n.go @@ -0,0 +1,158 @@ +package mediashare + +import "fmt" + +// Language represents a supported language code. +type Language string + +const ( + LangPolish Language = "pl" + LangEnglish Language = "en" +) + +// Translations holds all translatable strings. +var translations = map[Language]map[string]string{ + LangPolish: { + // Landing page + "uploaded_by": "Wysłane przez", + "download": "Pobierz", + "not_found": "Plik nie został znaleziony", + "not_found_desc": "Ten plik mógł wygasnąć lub został usunięty.", + "unsupported": "Nieobsługiwany typ pliku", + "unsupported_desc": "Ten typ pliku nie może być wyświetlony w przeglądarce.", + "powered_by": "Obsługiwane przez", + "uploaded_at": "Wysłano", + "file_info": "Informacje o pliku", + "direct_link": "Link bezpośredni", + "copy_link": "Kopiuj link", + "copied": "Skopiowano!", + "loading": "Ładowanie...", + "error_loading": "Błąd ładowania pliku", + "open_in_new_tab": "Otwórz w nowej karcie", + + // List page + "recent_files": "Ostatnie pliki", + "no_files": "Brak plików", + "no_files_desc": "Nie ma jeszcze żadnych przesłanych plików.", + "table_date": "Data", + "table_user": "Użytkownik", + "table_file": "Plik", + "table_link": "Link", + "table_last_opened": "Ostatnio otwarte", + "never": "nigdy", + "anonymous": "anonim", + + // IRC messages (format: "user udostępnił X: URL") + "shared_video": "%s udostępnił wideo", + "shared_voice": "%s udostępnił wiadomość głosową", + "shared_photo": "%s udostępnił zdjęcie", + "shared_file": "%s udostępnił plik", + "with_caption": " z opisem '%s'", + }, + LangEnglish: { + // Landing page + "uploaded_by": "Uploaded by", + "download": "Download", + "not_found": "File not found", + "not_found_desc": "This file may have expired or been deleted.", + "unsupported": "Unsupported file type", + "unsupported_desc": "This file type cannot be displayed in the browser.", + "powered_by": "Powered by", + "uploaded_at": "Uploaded", + "file_info": "File information", + "direct_link": "Direct link", + "copy_link": "Copy link", + "copied": "Copied!", + "loading": "Loading...", + "error_loading": "Error loading file", + "open_in_new_tab": "Open in new tab", + + // List page + "recent_files": "Recent files", + "no_files": "No files", + "no_files_desc": "There are no uploaded files yet.", + "table_date": "Date", + "table_user": "User", + "table_file": "File", + "table_link": "Link", + "table_last_opened": "Last opened", + "never": "never", + "anonymous": "anonymous", + + // IRC messages + "shared_video": "%s shared a video", + "shared_voice": "%s shared a voice message", + "shared_photo": "%s shared a photo", + "shared_file": "%s shared a file", + "with_caption": " with caption '%s'", + }, +} + +// I18n provides internationalization functionality. +type I18n struct { + lang Language +} + +// NewI18n creates a new I18n instance with the specified language. +// Falls back to Polish if the language is not supported. +func NewI18n(lang string) *I18n { + l := Language(lang) + if _, ok := translations[l]; !ok { + l = LangPolish + } + return &I18n{lang: l} +} + +// T returns the translation for the given key. +// Returns the key itself if translation is not found. +func (i *I18n) T(key string) string { + if trans, ok := translations[i.lang][key]; ok { + return trans + } + // Fallback to Polish + if trans, ok := translations[LangPolish][key]; ok { + return trans + } + return key +} + +// Tf returns a formatted translation for the given key. +func (i *I18n) Tf(key string, args ...interface{}) string { + format := i.T(key) + return fmt.Sprintf(format, args...) +} + +// Lang returns the current language code. +func (i *I18n) Lang() string { + return string(i.lang) +} + +// GetTranslations returns all translations for the current language. +// Useful for passing to templates. +func (i *I18n) GetTranslations() map[string]string { + return translations[i.lang] +} + +// FormatSharedMessage formats an IRC message for shared media. +func (i *I18n) FormatSharedMessage(username, mediaType, caption, url string) string { + var key string + switch mediaType { + case "video": + key = "shared_video" + case "voice", "voice message": + key = "shared_voice" + case "photo": + key = "shared_photo" + default: + key = "shared_file" + } + + msg := i.Tf(key, username) + if caption != "" { + msg += i.Tf("with_caption", caption) + } + if url != "" { + msg += ": " + url + } + return msg +} diff --git a/internal/mediashare/server.go b/internal/mediashare/server.go new file mode 100644 index 0000000..d91f460 --- /dev/null +++ b/internal/mediashare/server.go @@ -0,0 +1,457 @@ +package mediashare + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "regexp" + "strings" + "time" +) + +// Config holds the server configuration. +type Config struct { + Port int + APIKey string + BaseURL string + StoragePath string + DBPath string + MaxFileSize int64 + RetentionHours int + ServiceName string + Language string + ShowList bool + MainImage string +} + +// Server is the HTTP server for MediaShare. +type Server struct { + config *Config + storage *Storage + db *Database + i18n *I18n + mux *http.ServeMux +} + +// UploadResponse is returned after a successful upload. +type UploadResponse struct { + Success bool `json:"success"` + URL string `json:"url"` + RawURL string `json:"raw_url"` + ID string `json:"id"` + Filename string `json:"filename"` + Size int64 `json:"size"` +} + +// ErrorResponse is returned on errors. +type ErrorResponse struct { + Success bool `json:"success"` + Error string `json:"error"` +} + +// idPattern matches 5-character alphanumeric IDs +var idPattern = regexp.MustCompile(`^[a-zA-Z0-9]{5}$`) + +// NewServer creates a new MediaShare server. +func NewServer(config *Config) (*Server, error) { + // Initialize database + db, err := NewDatabase(config.DBPath) + if err != nil { + return nil, fmt.Errorf("failed to initialize database: %w", err) + } + + // Initialize i18n + i18n := NewI18n(config.Language) + + s := &Server{ + config: config, + db: db, + storage: NewStorage(config.StoragePath, config.MaxFileSize, db), + i18n: i18n, + mux: http.NewServeMux(), + } + s.setupRoutes() + + // Start cleanup worker + if config.RetentionHours > 0 { + s.startCleanupWorker() + } + + return s, nil +} + +func (s *Server) setupRoutes() { + s.mux.HandleFunc("/upload", s.handleUpload) + s.mux.HandleFunc("/r/", s.handleRaw) + s.mux.HandleFunc("/health", s.handleHealth) + s.mux.HandleFunc("/main-image", s.handleMainImageFile) + s.mux.HandleFunc("/", s.handleRoot) +} + +// ServeHTTP implements http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Add CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + s.mux.ServeHTTP(w, r) +} + +// Start begins listening on the configured port. +func (s *Server) Start() error { + addr := fmt.Sprintf(":%d", s.config.Port) + log.Printf("[%s] Server starting on %s", s.config.ServiceName, addr) + log.Printf("[%s] Base URL: %s", s.config.ServiceName, s.config.BaseURL) + log.Printf("[%s] Storage: %s", s.config.ServiceName, s.config.StoragePath) + log.Printf("[%s] Language: %s", s.config.ServiceName, s.config.Language) + log.Printf("[%s] Retention: %d hours", s.config.ServiceName, s.config.RetentionHours) + return http.ListenAndServe(addr, s) +} + +// Close cleans up resources. +func (s *Server) Close() error { + if s.db != nil { + return s.db.Close() + } + return nil +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "service": s.config.ServiceName, + }) +} + +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check API key + apiKey := r.Header.Get("X-API-Key") + if apiKey == "" { + apiKey = r.URL.Query().Get("key") + } + if s.config.APIKey != "" && apiKey != s.config.APIKey { + s.sendError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse multipart form + if err := r.ParseMultipartForm(s.config.MaxFileSize); err != nil { + s.sendError(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + s.sendError(w, "No file provided", http.StatusBadRequest) + return + } + defer file.Close() + + // Get username from form (optional) + username := r.FormValue("username") + + // Store the file + info, err := s.storage.Store(header.Filename, username, file) + if err != nil { + s.sendError(w, "Failed to store file: "+err.Error(), http.StatusInternalServerError) + return + } + + // Build response URLs + baseURL := strings.TrimSuffix(s.config.BaseURL, "/") + response := UploadResponse{ + Success: true, + URL: fmt.Sprintf("%s/%s", baseURL, info.ID), + RawURL: fmt.Sprintf("%s/r/%s", baseURL, info.ID), + ID: info.ID, + Filename: info.Filename, + Size: info.Size, + } + + log.Printf("[%s] Uploaded: %s (%s, %d bytes, user: %s)", + s.config.ServiceName, info.ID, info.Filename, info.Size, username) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Root path - show main image, file list, or 404 + if r.URL.Path == "/" { + if s.config.MainImage != "" { + s.handleMainImage(w, r) + } else if s.config.ShowList { + s.handleList(w, r) + } else { + s.sendNotFound(w) + } + return + } + + // Extract ID from path: /{id} + id := strings.TrimPrefix(r.URL.Path, "/") + + // Validate ID format (5 alphanumeric characters) + if !idPattern.MatchString(id) { + s.sendNotFound(w) + return + } + + // Get file info + info, err := s.storage.Get(id) + if err != nil || info == nil { + s.sendNotFound(w) + return + } + + // Update last opened timestamp + if err := s.storage.UpdateLastOpened(id); err != nil { + log.Printf("[%s] Failed to update last opened: %v", s.config.ServiceName, err) + } + + // Prepare template data + baseURL := strings.TrimSuffix(s.config.BaseURL, "/") + data := PageData{ + ID: info.ID, + Filename: info.Filename, + ContentType: info.ContentType, + Size: info.Size, + IsVideo: IsVideo(info.ContentType), + IsAudio: IsAudio(info.ContentType), + IsImage: IsImage(info.ContentType), + RawURL: fmt.Sprintf("%s/r/%s", baseURL, info.ID), + Username: info.Username, + UploadedAt: info.UploadedAt, + ServiceName: s.config.ServiceName, + Lang: s.i18n.Lang(), + T: s.i18n.GetTranslations(), + BaseURL: baseURL, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := PlayerTemplate.Execute(w, data); err != nil { + log.Printf("[%s] Template error: %v", s.config.ServiceName, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + // Get recent files from database (last 50) + records, err := s.db.GetRecentFiles(50) + if err != nil { + log.Printf("[%s] Failed to get recent files: %v", s.config.ServiceName, err) + records = []*FileRecord{} + } + + // Convert to template data + baseURL := strings.TrimSuffix(s.config.BaseURL, "/") + files := make([]FileListItem, 0, len(records)) + + for _, rec := range records { + // Determine content type category + contentType := "file" + if IsVideo(rec.ContentType) { + contentType = "video" + } else if IsAudio(rec.ContentType) { + contentType = "audio" + } else if IsImage(rec.ContentType) { + contentType = "image" + } + + // Format last opened + lastOpened := s.i18n.T("never") + if rec.LastOpenedAt != nil { + lastOpened = rec.LastOpenedAt.Format("2006-01-02 15:04") + } + + // Format username + username := rec.Username + if username == "" { + username = s.i18n.T("anonymous") + } + + files = append(files, FileListItem{ + ID: rec.ID, + Filename: rec.Filename, + ContentType: contentType, + Username: username, + UploadedAt: rec.UploadedAt.Format("2006-01-02 15:04"), + LastOpenedAt: lastOpened, + URL: fmt.Sprintf("%s/%s", baseURL, rec.ID), + RawURL: fmt.Sprintf("%s/r/%s", baseURL, rec.ID), + }) + } + + data := ListPageData{ + ServiceName: s.config.ServiceName, + Lang: s.i18n.Lang(), + T: s.i18n.GetTranslations(), + Files: files, + BaseURL: baseURL, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := ListTemplate.Execute(w, data); err != nil { + log.Printf("[%s] List template error: %v", s.config.ServiceName, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (s *Server) handleMainImage(w http.ResponseWriter, r *http.Request) { + // Use /main-image endpoint to serve the actual file + imagePath := "/main-image" + // If MainImage looks like a URL (starts with http), use it directly + if strings.HasPrefix(s.config.MainImage, "http://") || strings.HasPrefix(s.config.MainImage, "https://") { + imagePath = s.config.MainImage + } + + data := MainImageData{ + ServiceName: s.config.ServiceName, + Lang: s.i18n.Lang(), + T: s.i18n.GetTranslations(), + ImagePath: imagePath, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := MainImageTemplate.Execute(w, data); err != nil { + log.Printf("[%s] Main image template error: %v", s.config.ServiceName, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (s *Server) handleMainImageFile(w http.ResponseWriter, r *http.Request) { + if s.config.MainImage == "" { + http.NotFound(w, r) + return + } + + // Don't serve if it's an external URL + if strings.HasPrefix(s.config.MainImage, "http://") || strings.HasPrefix(s.config.MainImage, "https://") { + http.NotFound(w, r) + return + } + + http.ServeFile(w, r, s.config.MainImage) +} + +func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract ID from path: /r/{id} + id := strings.TrimPrefix(r.URL.Path, "/r/") + if id == "" || !idPattern.MatchString(id) { + s.sendNotFound(w) + return + } + + // Get file info + info, err := s.storage.Get(id) + if err != nil || info == nil { + s.sendNotFound(w) + return + } + + // Note: We don't update last_opened here because /r/ is used by + // mini-players on the list page. Only the player page (/{id}) updates it. + + // Serve the file + fullPath := s.storage.GetFullPath(info.Path) + w.Header().Set("Content-Type", info.ContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, info.Filename)) + http.ServeFile(w, r, fullPath) +} + +func (s *Server) sendError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(ErrorResponse{ + Success: false, + Error: message, + }) +} + +func (s *Server) sendNotFound(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + + data := NotFoundData{ + ServiceName: s.config.ServiceName, + Lang: s.i18n.Lang(), + T: s.i18n.GetTranslations(), + } + NotFoundTemplate.Execute(w, data) +} + +// startCleanupWorker starts a background goroutine that periodically cleans up expired files. +func (s *Server) startCleanupWorker() { + ticker := time.NewTicker(1 * time.Hour) + go func() { + // Run cleanup immediately on start + s.cleanupExpiredFiles() + + for range ticker.C { + s.cleanupExpiredFiles() + } + }() + log.Printf("[%s] Cleanup worker started (every 1 hour, retention: %d hours)", + s.config.ServiceName, s.config.RetentionHours) +} + +// cleanupExpiredFiles removes files that haven't been opened within the retention period. +func (s *Server) cleanupExpiredFiles() { + files, err := s.storage.GetExpiredFiles(s.config.RetentionHours) + if err != nil { + log.Printf("[%s] Cleanup error: %v", s.config.ServiceName, err) + return + } + + if len(files) == 0 { + return + } + + log.Printf("[%s] Cleaning up %d expired files...", s.config.ServiceName, len(files)) + + for _, f := range files { + // Remove file from disk + fullPath := s.storage.GetFullPath(f.Path) + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + log.Printf("[%s] Failed to remove file %s: %v", s.config.ServiceName, f.ID, err) + continue + } + + // Remove from database + if err := s.db.Delete(f.ID); err != nil { + log.Printf("[%s] Failed to delete record %s: %v", s.config.ServiceName, f.ID, err) + continue + } + + log.Printf("[%s] Cleaned up: %s (%s)", s.config.ServiceName, f.ID, f.Filename) + } +} + +// GetI18n returns the i18n instance for use by external code (like TeleIRC). +func (s *Server) GetI18n() *I18n { + return s.i18n +} diff --git a/internal/mediashare/server_test.go b/internal/mediashare/server_test.go new file mode 100644 index 0000000..5b85e9e --- /dev/null +++ b/internal/mediashare/server_test.go @@ -0,0 +1,301 @@ +package mediashare + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func setupTestServer(t *testing.T) (*Server, string) { + tmpDir, err := os.MkdirTemp("", "mediashare_server_test") + if err != nil { + t.Fatal(err) + } + + dbPath := tmpDir + "/test.db" + config := &Config{ + Port: 8080, + APIKey: "testkey", + BaseURL: "http://localhost:8080", + StoragePath: tmpDir, + DBPath: dbPath, + MaxFileSize: 10 * 1024 * 1024, + RetentionHours: 72, + ServiceName: "MediaShareTest", + Language: "en", + } + + server, err := NewServer(config) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatal(err) + } + return server, tmpDir +} + +func TestHealthEndpoint(t *testing.T) { + server, tmpDir := setupTestServer(t) + defer os.RemoveAll(tmpDir) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + + server.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]string + json.NewDecoder(w.Body).Decode(&response) + + if response["status"] != "ok" { + t.Errorf("Expected status ok, got %s", response["status"]) + } +} + +func TestUploadWithoutAPIKey(t *testing.T) { + server, tmpDir := setupTestServer(t) + defer os.RemoveAll(tmpDir) + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.txt") + part.Write([]byte("test content")) + writer.Close() + + req := httptest.NewRequest("POST", "/upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + server.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +func TestUploadWithAPIKey(t *testing.T) { + server, tmpDir := setupTestServer(t) + defer os.RemoveAll(tmpDir) + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.txt") + part.Write([]byte("test content")) + writer.Close() + + req := httptest.NewRequest("POST", "/upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-API-Key", "testkey") + w := httptest.NewRecorder() + + server.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response UploadResponse + json.NewDecoder(w.Body).Decode(&response) + + if !response.Success { + t.Error("Expected success to be true") + } + + if response.ID == "" { + t.Error("Expected ID to be set") + } + + if response.URL == "" { + t.Error("Expected URL to be set") + } +} + +func TestUploadWithQueryKey(t *testing.T) { + server, tmpDir := setupTestServer(t) + defer os.RemoveAll(tmpDir) + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.txt") + part.Write([]byte("test content")) + writer.Close() + + req := httptest.NewRequest("POST", "/upload?key=testkey", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + server.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +func TestViewEndpoint(t *testing.T) { + server, tmpDir := setupTestServer(t) + defer os.RemoveAll(tmpDir) + + // First upload a file + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.mp4") + part.Write([]byte("fake video content")) + writer.Close() + + uploadReq := httptest.NewRequest("POST", "/upload", body) + uploadReq.Header.Set("Content-Type", writer.FormDataContentType()) + uploadReq.Header.Set("X-API-Key", "testkey") + uploadW := httptest.NewRecorder() + + server.ServeHTTP(uploadW, uploadReq) + + var uploadResponse UploadResponse + json.NewDecoder(uploadW.Body).Decode(&uploadResponse) + + // Now view it (new URL pattern: /{id} instead of /v/{id}) + viewReq := httptest.NewRequest("GET", "/"+uploadResponse.ID, nil) + viewW := httptest.NewRecorder() + + server.ServeHTTP(viewW, viewReq) + + if viewW.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", viewW.Code) + } + + contentType := viewW.Header().Get("Content-Type") + if contentType != "text/html; charset=utf-8" { + t.Errorf("Expected HTML content type, got %s", contentType) + } +} + +func TestRawEndpoint(t *testing.T) { + server, tmpDir := setupTestServer(t) + defer os.RemoveAll(tmpDir) + + // First upload a file + fileContent := "raw file content" + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.txt") + part.Write([]byte(fileContent)) + writer.Close() + + uploadReq := httptest.NewRequest("POST", "/upload", body) + uploadReq.Header.Set("Content-Type", writer.FormDataContentType()) + uploadReq.Header.Set("X-API-Key", "testkey") + uploadW := httptest.NewRecorder() + + server.ServeHTTP(uploadW, uploadReq) + + var uploadResponse UploadResponse + json.NewDecoder(uploadW.Body).Decode(&uploadResponse) + + // Now get raw + rawReq := httptest.NewRequest("GET", "/r/"+uploadResponse.ID, nil) + rawW := httptest.NewRecorder() + + server.ServeHTTP(rawW, rawReq) + + if rawW.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rawW.Code) + } + + responseBody, _ := io.ReadAll(rawW.Body) + if string(responseBody) != fileContent { + t.Errorf("Expected %q, got %q", fileContent, string(responseBody)) + } +} + +func TestNotFound(t *testing.T) { + server, tmpDir := setupTestServer(t) + defer os.RemoveAll(tmpDir) + defer server.Close() + + // Test with valid-looking ID that doesn't exist (5 alphanumeric chars) + req := httptest.NewRequest("GET", "/AbCd1", nil) + w := httptest.NewRecorder() + + server.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestListEndpoint(t *testing.T) { + // Create test server with ShowList enabled + tmpDir, err := os.MkdirTemp("", "mediashare_list_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + config := &Config{ + Port: 8080, + APIKey: "testkey", + BaseURL: "http://localhost:8080", + StoragePath: tmpDir, + DBPath: tmpDir + "/test.db", + MaxFileSize: 10 * 1024 * 1024, + RetentionHours: 72, + ServiceName: "MediaShareTest", + Language: "en", + ShowList: true, + } + + server, err := NewServer(config) + if err != nil { + t.Fatal(err) + } + defer server.Close() + + // First upload a file + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.mp4") + part.Write([]byte("fake video content")) + writer.WriteField("username", "testuser") + writer.Close() + + uploadReq := httptest.NewRequest("POST", "/upload", body) + uploadReq.Header.Set("Content-Type", writer.FormDataContentType()) + uploadReq.Header.Set("X-API-Key", "testkey") + uploadW := httptest.NewRecorder() + + server.ServeHTTP(uploadW, uploadReq) + + // Now check the list page + listReq := httptest.NewRequest("GET", "/", nil) + listW := httptest.NewRecorder() + + server.ServeHTTP(listW, listReq) + + if listW.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", listW.Code) + } + + contentType := listW.Header().Get("Content-Type") + if contentType != "text/html; charset=utf-8" { + t.Errorf("Expected HTML content type, got %s", contentType) + } + + // Check that response contains the uploaded file info + responseBody := listW.Body.String() + if !strings.Contains(responseBody, "test.mp4") { + t.Error("Expected list page to contain uploaded filename") + } + if !strings.Contains(responseBody, "testuser") { + t.Error("Expected list page to contain username") + } +} diff --git a/internal/mediashare/storage.go b/internal/mediashare/storage.go new file mode 100644 index 0000000..0480cf5 --- /dev/null +++ b/internal/mediashare/storage.go @@ -0,0 +1,265 @@ +// Package mediashare provides a simple file hosting service with HTML5 player support. +package mediashare + +import ( + "crypto/rand" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" +) + +// FileInfo contains metadata about a stored file. +type FileInfo struct { + ID string + Filename string + ContentType string + Size int64 + Path string + Username string + UploadedAt time.Time + LastOpenedAt *time.Time + OpenCount int +} + +// Storage handles file storage operations. +type Storage struct { + BasePath string + MaxFileSize int64 + db *Database +} + +// NewStorage creates a new Storage instance. +func NewStorage(basePath string, maxFileSize int64, db *Database) *Storage { + return &Storage{ + BasePath: basePath, + MaxFileSize: maxFileSize, + db: db, + } +} + +// GenerateID creates a random 5-character alphanumeric ID. +func GenerateID() string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 5) + rand.Read(b) + for i := range b { + b[i] = charset[int(b[i])%len(charset)] + } + return string(b) +} + +// GetDatePath returns a path in YYYY/MM/DD format for the current date. +func GetDatePath() string { + now := time.Now() + return fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day()) +} + +// Store saves a file and returns its FileInfo. +func (s *Storage) Store(filename string, username string, reader io.Reader) (*FileInfo, error) { + // Generate unique ID (check database for collisions) + var id string + for { + id = GenerateID() + if s.db != nil { + exists, err := s.db.Exists(id) + if err != nil { + return nil, fmt.Errorf("failed to check ID existence: %w", err) + } + if !exists { + break + } + } else { + break + } + } + + // Sanitize and get extension + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + ext = ".bin" + } + + // Create date-based directory + datePath := GetDatePath() + dirPath := filepath.Join(s.BasePath, datePath) + if err := os.MkdirAll(dirPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + // Full path for the file + storedFilename := id + ext + fullPath := filepath.Join(dirPath, storedFilename) + + // Create file + file, err := os.Create(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + // Copy content with size limit + limitedReader := io.LimitReader(reader, s.MaxFileSize+1) + written, err := io.Copy(file, limitedReader) + if err != nil { + os.Remove(fullPath) + return nil, fmt.Errorf("failed to write file: %w", err) + } + + if written > s.MaxFileSize { + os.Remove(fullPath) + return nil, fmt.Errorf("file exceeds maximum size of %d bytes", s.MaxFileSize) + } + + // Detect content type + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + + now := time.Now() + relativePath := filepath.Join(datePath, storedFilename) + + info := &FileInfo{ + ID: id, + Filename: filename, + ContentType: contentType, + Size: written, + Path: relativePath, + Username: username, + UploadedAt: now, + } + + // Save to database if available + if s.db != nil { + record := &FileRecord{ + ID: id, + Filename: filename, + ContentType: contentType, + Size: written, + Path: relativePath, + Username: username, + UploadedAt: now, + } + if err := s.db.Insert(record); err != nil { + os.Remove(fullPath) + return nil, fmt.Errorf("failed to save file record: %w", err) + } + } + + return info, nil +} + +// Get retrieves file information by ID from the database. +func (s *Storage) Get(id string) (*FileInfo, error) { + if s.db == nil { + return nil, fmt.Errorf("database not available") + } + + record, err := s.db.Get(id) + if err != nil { + return nil, err + } + if record == nil { + return nil, nil + } + + return &FileInfo{ + ID: record.ID, + Filename: record.Filename, + ContentType: record.ContentType, + Size: record.Size, + Path: record.Path, + Username: record.Username, + UploadedAt: record.UploadedAt, + LastOpenedAt: record.LastOpenedAt, + OpenCount: record.OpenCount, + }, nil +} + +// UpdateLastOpened updates the last opened timestamp for a file. +func (s *Storage) UpdateLastOpened(id string) error { + if s.db == nil { + return nil + } + return s.db.UpdateLastOpened(id) +} + +// GetFullPath returns the full filesystem path for a file. +func (s *Storage) GetFullPath(relativePath string) string { + return filepath.Join(s.BasePath, relativePath) +} + +// Delete removes a file by ID. +func (s *Storage) Delete(id string) error { + info, err := s.Get(id) + if err != nil { + return err + } + if info == nil { + return fmt.Errorf("file not found: %s", id) + } + + // Remove from filesystem + fullPath := s.GetFullPath(info.Path) + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove file: %w", err) + } + + // Remove from database + if s.db != nil { + if err := s.db.Delete(id); err != nil { + return fmt.Errorf("failed to delete record: %w", err) + } + } + + return nil +} + +// GetExpiredFiles returns files that haven't been opened within the retention period. +func (s *Storage) GetExpiredFiles(retentionHours int) ([]*FileInfo, error) { + if s.db == nil { + return nil, nil + } + + cutoff := time.Now().Add(-time.Duration(retentionHours) * time.Hour) + records, err := s.db.GetFilesNotOpenedSince(cutoff) + if err != nil { + return nil, err + } + + var infos []*FileInfo + for _, r := range records { + infos = append(infos, &FileInfo{ + ID: r.ID, + Filename: r.Filename, + ContentType: r.ContentType, + Size: r.Size, + Path: r.Path, + Username: r.Username, + UploadedAt: r.UploadedAt, + LastOpenedAt: r.LastOpenedAt, + OpenCount: r.OpenCount, + }) + } + + return infos, nil +} + +// IsVideo checks if the content type is a video. +func IsVideo(contentType string) bool { + return strings.HasPrefix(contentType, "video/") +} + +// IsAudio checks if the content type is audio. +func IsAudio(contentType string) bool { + return strings.HasPrefix(contentType, "audio/") +} + +// IsImage checks if the content type is an image. +func IsImage(contentType string) bool { + return strings.HasPrefix(contentType, "image/") +} diff --git a/internal/mediashare/storage_test.go b/internal/mediashare/storage_test.go new file mode 100644 index 0000000..f03d469 --- /dev/null +++ b/internal/mediashare/storage_test.go @@ -0,0 +1,204 @@ +package mediashare + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateID(t *testing.T) { + id1 := GenerateID() + id2 := GenerateID() + + if len(id1) != 5 { + t.Errorf("Expected ID length 5, got %d", len(id1)) + } + + if id1 == id2 { + t.Error("Generated IDs should be unique") + } + + // Check alphanumeric + for _, c := range id1 { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + t.Errorf("Invalid character in ID: %c", c) + } + } +} + +func setupTestDB(t *testing.T, tmpDir string) *Database { + dbPath := filepath.Join(tmpDir, "test.db") + db, err := NewDatabase(dbPath) + if err != nil { + t.Fatal(err) + } + return db +} + +func TestStorage_Store(t *testing.T) { + // Create temp directory + tmpDir, err := os.MkdirTemp("", "mediashare_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + db := setupTestDB(t, tmpDir) + defer db.Close() + + storage := NewStorage(tmpDir, 10*1024*1024, db) + + // Test storing a file + content := "test content" + info, err := storage.Store("test.txt", "testuser", strings.NewReader(content)) + if err != nil { + t.Fatalf("Failed to store file: %v", err) + } + + if info.ID == "" { + t.Error("ID should not be empty") + } + + if len(info.ID) != 5 { + t.Errorf("Expected ID length 5, got %d", len(info.ID)) + } + + if info.Filename != "test.txt" { + t.Errorf("Expected filename test.txt, got %s", info.Filename) + } + + if info.Size != int64(len(content)) { + t.Errorf("Expected size %d, got %d", len(content), info.Size) + } + + if info.ContentType != "text/plain; charset=utf-8" { + t.Errorf("Expected text/plain content type, got %s", info.ContentType) + } + + if info.Username != "testuser" { + t.Errorf("Expected username testuser, got %s", info.Username) + } + + // Verify file exists + fullPath := filepath.Join(tmpDir, info.Path) + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + t.Error("File should exist on disk") + } +} + +func TestStorage_Get(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mediashare_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + db := setupTestDB(t, tmpDir) + defer db.Close() + + storage := NewStorage(tmpDir, 10*1024*1024, db) + + // Store a file first + content := "test content for get" + info, err := storage.Store("gettest.txt", "testuser", strings.NewReader(content)) + if err != nil { + t.Fatalf("Failed to store file: %v", err) + } + + // Now retrieve it + retrieved, err := storage.Get(info.ID) + if err != nil { + t.Fatalf("Failed to get file: %v", err) + } + + if retrieved.ID != info.ID { + t.Errorf("ID mismatch: expected %s, got %s", info.ID, retrieved.ID) + } + + if retrieved.Size != info.Size { + t.Errorf("Size mismatch: expected %d, got %d", info.Size, retrieved.Size) + } + + if retrieved.Username != "testuser" { + t.Errorf("Username mismatch: expected testuser, got %s", retrieved.Username) + } +} + +func TestStorage_MaxFileSize(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mediashare_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + db := setupTestDB(t, tmpDir) + defer db.Close() + + // Set max size to 10 bytes + storage := NewStorage(tmpDir, 10, db) + + // Try to store a file larger than limit + content := "this content is definitely longer than 10 bytes" + _, err = storage.Store("big.txt", "testuser", strings.NewReader(content)) + if err == nil { + t.Error("Expected error for file exceeding max size") + } +} + +func TestIsVideo(t *testing.T) { + tests := []struct { + contentType string + expected bool + }{ + {"video/mp4", true}, + {"video/webm", true}, + {"audio/mp3", false}, + {"image/png", false}, + } + + for _, tt := range tests { + result := IsVideo(tt.contentType) + if result != tt.expected { + t.Errorf("IsVideo(%s) = %v, expected %v", tt.contentType, result, tt.expected) + } + } +} + +func TestIsAudio(t *testing.T) { + tests := []struct { + contentType string + expected bool + }{ + {"audio/mp3", true}, + {"audio/ogg", true}, + {"video/mp4", false}, + {"image/png", false}, + } + + for _, tt := range tests { + result := IsAudio(tt.contentType) + if result != tt.expected { + t.Errorf("IsAudio(%s) = %v, expected %v", tt.contentType, result, tt.expected) + } + } +} + +func TestIsImage(t *testing.T) { + tests := []struct { + contentType string + expected bool + }{ + {"image/png", true}, + {"image/jpeg", true}, + {"video/mp4", false}, + {"audio/mp3", false}, + } + + for _, tt := range tests { + result := IsImage(tt.contentType) + if result != tt.expected { + t.Errorf("IsImage(%s) = %v, expected %v", tt.contentType, result, tt.expected) + } + } +} diff --git a/internal/mediashare/templates.go b/internal/mediashare/templates.go new file mode 100644 index 0000000..6110269 --- /dev/null +++ b/internal/mediashare/templates.go @@ -0,0 +1,619 @@ +package mediashare + +import ( + "html/template" + "time" +) + +// PageData contains data for rendering the player page. +type PageData struct { + ID string + Filename string + ContentType string + Size int64 + IsVideo bool + IsAudio bool + IsImage bool + RawURL string + Username string + UploadedAt time.Time + ServiceName string + Lang string + T map[string]string + BaseURL string +} + +// NotFoundData contains data for the 404 page. +type NotFoundData struct { + ServiceName string + Lang string + T map[string]string +} + +// FileListItem represents a file in the list view. +type FileListItem struct { + ID string + Filename string + ContentType string + Username string + UploadedAt string + LastOpenedAt string + URL string + RawURL string +} + +// ListPageData contains data for the file list page. +type ListPageData struct { + ServiceName string + Lang string + T map[string]string + Files []FileListItem + BaseURL string +} + +// MainImageData contains data for the main image page. +type MainImageData struct { + ServiceName string + Lang string + T map[string]string + ImagePath string +} + +const playerTemplateHTML = ` + +
+ + +{{index .T "unsupported"}}
+{{index .T "no_files_desc"}}
+