Image Style Options
Selfie provides powerful image resizing capabilities through the styles configuration in your domain’s attachmentOptions.
Resize Modes
fit
The fit mode scales the image to fit within the specified dimensions while maintaining aspect ratio. The resulting image will be no larger than the specified width and height:
static attachmentOptions = [
photo: [
styles: [
thumb: [width: 150, height: 150, mode: 'fit']
]
]
]
If you upload a 1000x500 image with the above configuration, the result will be 150x75 (maintaining aspect ratio).
scale
The scale mode scales the image proportionally based on the width parameter only:
static attachmentOptions = [
photo: [
styles: [
medium: [width: 500, mode: 'scale']
]
]
]
crop
The crop mode creates a centered crop of the specified dimensions:
static attachmentOptions = [
photo: [
styles: [
square: [width: 200, height: 200, mode: 'crop']
]
]
]
This is useful for creating uniform thumbnails like profile pictures.
Multiple Styles
You can define multiple styles for a single attachment:
class Product {
String name
Attachment image
static attachmentOptions = [
image: [
styles: [
thumbnail: [width: 100, height: 100, mode: 'crop'],
small: [width: 300, mode: 'scale'],
medium: [width: 600, mode: 'scale'],
large: [width: 1200, mode: 'scale']
]
]
]
static embedded = ['image']
static constraints = {
image contentType: ['png', 'jpg', 'jpeg', 'webp']
}
}
Accessing Different Styles
Each style generates a separate file that you can access:
def product = Product.get(1)
// Original image URL
println product.image.url
// Thumbnail URL
println product.image.thumbnail.url
// Medium size URL
println product.image.medium.url
In a GSP view:
<img src="${product.image.thumbnail.url}" alt="Thumbnail">
<img src="${product.image.medium.url}" alt="Medium">
Content Type Validation
Restrict which file types can be uploaded using the contentType constraint:
Single Content Type
static constraints = {
photo contentType: 'png'
}
Multiple Content Types
static constraints = {
photo contentType: ['png', 'jpg', 'jpeg', 'gif', 'webp']
}
MIME Type Validation
You can also use full MIME types:
static constraints = {
document contentType: ['application/pdf', 'application/msword', 'text/plain']
}
File Size Validation
Limit the maximum file size in bytes:
static constraints = {
// 5MB maximum
photo fileSize: 5 * 1024 * 1024
// Or be more explicit
avatar fileSize: [max: 2 * 1024 * 1024] // 2MB
}
Combined Validation
static constraints = {
photo(
contentType: ['png', 'jpg', 'jpeg'],
fileSize: 10 * 1024 * 1024, // 10MB
nullable: false
)
}
Storage Path Customization
The path configuration supports several placeholders for dynamic path generation:
grails {
plugin {
selfie {
storage {
path = 'uploads/:class/:id/:propertyName/:style/:filename'
bucket = 'my-bucket'
}
}
}
}
Available Placeholders
-
:class- Domain class name (e.g., "book") -
:id- Domain instance ID -
:propertyName- Property name (e.g., "photo") -
:style- Image style name (e.g., "thumbnail", "medium") -
:filename- Original filename
Example Path Results
With the path uploads/:class/:id/:propertyName/:style/:filename and a Book with ID 123 and a photo named "cover.jpg":
-
Original:
uploads/book/123/photo/original/cover.jpg -
Thumbnail:
uploads/book/123/photo/thumbnail/cover.jpg -
Medium:
uploads/book/123/photo/medium/cover.jpg
Manual Attachment Conversion
You can manually convert a MultipartFile to an Attachment:
In a Service
import com.bertramlabs.plugins.selfie.Attachment
import com.bertramlabs.plugins.selfie.AttachmentValueConverter
import org.springframework.web.multipart.MultipartFile
class PhotoService {
def uploadPhoto(String title, MultipartFile file) {
def photo = new AttachmentValueConverter().convert(file)
def book = new Book(title: title, photo: photo)
if (book.save()) {
return book
} else {
throw new RuntimeException("Failed to save book: ${book.errors}")
}
}
def replacePhoto(Long bookId, MultipartFile file) {
def book = Book.get(bookId)
if (!book) {
throw new RuntimeException("Book not found")
}
book.photo = new AttachmentValueConverter().convert(file)
book.save(flush: true)
return book
}
}
Direct File Upload
You can also create attachments from byte arrays or input streams:
import com.bertramlabs.plugins.selfie.Attachment
class FileService {
def createFromBytes(byte[] data, String filename, String contentType) {
def attachment = new Attachment(
fileName: filename,
contentType: contentType
)
attachment.inputStream = new ByteArrayInputStream(data)
return attachment
}
def createFromUrl(String imageUrl) {
URL url = new URL(imageUrl)
def connection = url.openConnection()
def attachment = new Attachment(
fileName: url.file.tokenize('/').last(),
contentType: connection.contentType
)
attachment.inputStream = connection.inputStream
return attachment
}
}
Accessing File URLs and Metadata
Getting File URLs
def book = Book.get(1)
// Original image URL
String originalUrl = book.photo.url
// Specific style URL
String thumbnailUrl = book.photo.thumbnail.url
// In a controller
render view: 'show', model: [book: book, photoUrl: book.photo.url]
File Metadata
Access various properties of uploaded files:
def book = Book.get(1)
def photo = book.photo
println "Filename: ${photo.fileName}"
println "Content Type: ${photo.contentType}"
println "File Size: ${photo.fileSize} bytes"
println "Original URL: ${photo.url}"
// Check if file exists
if (photo.exists()) {
println "File exists in storage"
}
// Get all available styles
photo.styles.each { styleName, styleAttachment ->
println "Style: ${styleName}, URL: ${styleAttachment.url}"
}
In GSP Views
<g:if test="${book.photo}">
<div class="photo-container">
<img src="${book.photo.thumbnail.url}"
alt="${book.name}"
class="thumbnail"/>
<a href="${book.photo.url}" target="_blank">View Full Size</a>
<ul class="available-sizes">
<li><a href="${book.photo.thumbnail.url}">Thumbnail</a></li>
<li><a href="${book.photo.medium.url}">Medium</a></li>
<li><a href="${book.photo.url}">Original</a></li>
</ul>
</div>
</g:if>
<g:else>
<p>No photo available</p>
</g:else>
Programmatic File Management
Deleting Attachments
When you delete a domain instance with attachments, Selfie automatically removes the files:
def book = Book.get(1)
book.delete() // Automatically deletes photo files from storage
To remove just the attachment:
def book = Book.get(1)
book.photo = null
book.save() // Removes the attachment but keeps the book record
Replacing Attachments
Simply assign a new file:
def book = Book.get(1)
book.photo = new AttachmentValueConverter().convert(newFile)
book.save() // Old photo is replaced
Copying Attachments
class BookService {
def duplicateBook(Long bookId) {
def original = Book.get(bookId)
def copy = new Book(name: original.name + " (Copy)")
// Clone the attachment
if (original.photo) {
copy.photo = original.photo.clone()
}
copy.save()
return copy
}
}
Multiple Attachments
You can have multiple attachment properties on a single domain:
import com.bertramlabs.plugins.selfie.Attachment
class Product {
String name
Attachment mainImage
Attachment gallery1
Attachment gallery2
Attachment gallery3
Attachment productSheet // PDF
static attachmentOptions = [
mainImage: [
styles: [
thumbnail: [width: 100, height: 100, mode: 'crop'],
large: [width: 800, mode: 'scale']
]
],
gallery1: [
styles: [
thumbnail: [width: 150, height: 150, mode: 'fit'],
medium: [width: 500, mode: 'scale']
]
],
gallery2: [
styles: [
thumbnail: [width: 150, height: 150, mode: 'fit'],
medium: [width: 500, mode: 'scale']
]
],
gallery3: [
styles: [
thumbnail: [width: 150, height: 150, mode: 'fit'],
medium: [width: 500, mode: 'scale']
]
]
]
static embedded = ['mainImage', 'gallery1', 'gallery2', 'gallery3', 'productSheet']
static constraints = {
mainImage contentType: ['png', 'jpg', 'jpeg'], fileSize: 5 * 1024 * 1024
gallery1 nullable: true, contentType: ['png', 'jpg', 'jpeg']
gallery2 nullable: true, contentType: ['png', 'jpg', 'jpeg']
gallery3 nullable: true, contentType: ['png', 'jpg', 'jpeg']
productSheet nullable: true, contentType: ['application/pdf']
}
}
Upload Form
<g:uploadForm name="productForm" url="[action:'save', controller:'product']">
<g:textField name="name" placeholder="Product Name"/><br/>
<label>Main Image:</label>
<input type="file" name="mainImage" accept="image/*"/><br/>
<label>Gallery Images:</label>
<input type="file" name="gallery1" accept="image/*"/><br/>
<input type="file" name="gallery2" accept="image/*"/><br/>
<input type="file" name="gallery3" accept="image/*"/><br/>
<label>Product Sheet (PDF):</label>
<input type="file" name="productSheet" accept="application/pdf"/><br/>
<g:submitButton name="save" value="Save Product"/>
</g:uploadForm>
Working with Collections
For dynamic galleries, consider storing a list of image identifiers and using a separate domain:
class Product {
String name
Attachment mainImage
static hasMany = [galleryImages: GalleryImage]
static embedded = ['mainImage']
}
class GalleryImage {
Attachment image
Integer sortOrder
static belongsTo = [product: Product]
static embedded = ['image']
static attachmentOptions = [
image: [
styles: [
thumbnail: [width: 150, height: 150, mode: 'crop'],
medium: [width: 600, mode: 'scale']
]
]
]
}
Security Considerations
Private Files
For secure file storage, configure your storage provider with private ACLs:
grails {
plugin {
selfie {
storage {
defaultFileACL = 'Private' // Options: Private, PublicRead
}
}
}
}
Signed URLs
For AWS S3, you can generate temporary signed URLs:
import com.bertramlabs.plugins.karman.CloudFile
class DocumentService {
def getSecureDownloadUrl(Document document, Integer expirySeconds = 3600) {
// This requires the CloudFile reference
CloudFile file = document.attachment.cloudFile
// Generate signed URL valid for specified duration
return file.getURL(expirySeconds)
}
}
File Type Verification
Beyond content type constraints, consider verifying file contents:
import org.apache.tika.Tika
class FileValidationService {
def validateFileType(MultipartFile file) {
Tika tika = new Tika()
String detectedType = tika.detect(file.inputStream)
def allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
if (!allowedTypes.contains(detectedType)) {
throw new RuntimeException("Invalid file type: ${detectedType}")
}
return true
}
}
Add Tika dependency:
dependencies {
implementation 'org.apache.tika:tika-core:2.9.0'
}
Environment-Specific Configuration
Use different storage providers for different environments:
environments {
development {
grails.plugin.selfie.storage.providerOptions {
provider = 'local'
basePath = 'storage'
baseUrl = 'http://localhost:8080/storage'
}
}
test {
grails.plugin.selfie.storage.providerOptions {
provider = 'local'
basePath = '/tmp/test-storage'
baseUrl = 'http://localhost:8080/storage'
}
}
production {
grails.plugin.selfie.storage.providerOptions {
provider = 's3'
accessKey = System.getenv('AWS_ACCESS_KEY')
secretKey = System.getenv('AWS_SECRET_KEY')
region = 'us-east-1'
}
}
}
Performance Tips
Lazy Loading
Attachments are stored as embedded objects, so they’re loaded with the domain instance. For large collections, consider pagination:
def products = Product.list(max: 20, offset: params.offset ?: 0)
Async Processing
For large images, consider processing styles asynchronously:
import grails.async.Promise
import static grails.async.Promises.task
class ProductService {
def saveProductAsync(Product product) {
// Save with original only
product.save(flush: true)
// Process styles in background
task {
product.mainImage.processStyles()
}
return product
}
}
CDN Usage
For production, use a CDN in front of your S3 bucket:
grails {
plugin {
selfie {
storage {
providerOptions {
provider = 's3'
accessKey = System.getenv('AWS_ACCESS_KEY')
secretKey = System.getenv('AWS_SECRET_KEY')
region = 'us-east-1'
}
// Override base URL to use CloudFront
cdnUrl = 'https://d1234567890.cloudfront.net'
}
}
}
}
Troubleshooting
Files Not Saving
Check that:
-
Your domain has
static embedded = ['attachmentProperty'] -
You’re calling
save()on the domain instance -
Storage provider credentials are correct
-
Bucket/directory permissions allow writes
Images Not Resizing
Verify:
-
imgscalr is on the classpath (included with Selfie)
-
The uploaded file is a valid image format
-
Style configuration is in
attachmentOptionsnotconstraints
URLs Not Working
Ensure:
-
For local storage: Karman
serveLocalStorageis enabled -
For S3: Bucket ACLs allow public read (if needed)
-
baseUrlis correctly configured
Memory Issues with Large Files
Use streaming instead of loading entire files:
// Instead of:
cloudFile.bytes = file.bytes
// Use:
cloudFile.inputStream = file.inputStream