diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 000000000..82968b9e6 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,17 @@ +--- +engines: + duplication: + enabled: true + config: + languages: + - ruby + fixme: + enabled: true + rubocop: + enabled: true +ratings: + paths: + - "**.rb" +exclude_paths: +- features/ +- spec/ diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 000000000..f637e41bc --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,3 @@ +## Deprecation notice + +Paperclip is currently undergoing [deprecation in favor of ActiveStorage](https://github.com/thoughtbot/paperclip/blob/master/MIGRATING.md). Maintainers of this repository will no longer be tending to new issues. We're leaving the issues page open so Paperclip users can still see & search through old issues, and continue existing discussions if they wish. diff --git a/.github/workflows/publish-gem.yml b/.github/workflows/publish-gem.yml new file mode 100644 index 000000000..f031c321a --- /dev/null +++ b/.github/workflows/publish-gem.yml @@ -0,0 +1,28 @@ +# This workflow should be deleted once we are done publishing "historical" gems. +name: Publish Gem (historical) + +on: [workflow_dispatch] + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Publish + run: | + # dump the gemspec just for debugging + echo "Gemspec:" + cat *.gemspec + + # publish the gem + echo "Publishing gem..." + mkdir -p $HOME/.gem + touch $HOME/.gem/credentials + chmod 0600 $HOME/.gem/credentials + printf -- "---\n:github: Bearer ${{secrets.GITHUB_TOKEN}}\n" > $HOME/.gem/credentials + gem build *.gemspec + gem push --KEY github --host https://rubygems.pkg.github.com/onelogin *.gem + echo "Done!" diff --git a/.gitignore b/.gitignore index fe1f103b5..36dfb6bb5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,9 @@ .rvmrc .bundle tmp +.DS_Store -test/s3.yml -test/debug.log -test/paperclip.db -test/doc -test/pkg -test/tmp +*.log public paperclip*.gem diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 000000000..312c29135 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,1055 @@ +AllCops: + Include: + - "**/*.gemspec" + - "**/*.podspec" + - "**/*.jbuilder" + - "**/*.rake" + - "**/*.opal" + - "**/Gemfile" + - "**/Rakefile" + - "**/Capfile" + - "**/Guardfile" + - "**/Podfile" + - "**/Thorfile" + - "**/Vagrantfile" + - "**/Berksfile" + - "**/Cheffile" + - "**/Vagabondfile" + Exclude: + - "vendor/**/*" + - "db/schema.rb" + - 'vendor/**/*' + - 'gemfiles/vendor/**/*' + Rails: + Enabled: false + DisplayCopNames: false + StyleGuideCopsOnly: false +Style/AccessModifierIndentation: + Description: Check indentation of private/protected visibility modifiers. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#indent-public-private-protected + Enabled: true + EnforcedStyle: indent + SupportedStyles: + - outdent + - indent +Style/AlignHash: + Description: Align the elements of a hash literal if they span more than one line. + Enabled: true + EnforcedHashRocketStyle: key + EnforcedColonStyle: key + EnforcedLastArgumentHashStyle: always_inspect + SupportedLastArgumentHashStyles: + - always_inspect + - always_ignore + - ignore_implicit + - ignore_explicit +Style/AlignParameters: + Description: Align the parameters of a method call if they span more than one line. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-double-indent + Enabled: true + EnforcedStyle: with_first_parameter + SupportedStyles: + - with_first_parameter + - with_fixed_indentation +Style/AndOr: + Description: Use &&/|| instead of and/or. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-and-or-or + Enabled: true + EnforcedStyle: always + SupportedStyles: + - always + - conditionals +Style/BarePercentLiterals: + Description: Checks if usage of %() or %Q() matches configuration. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-q-shorthand + Enabled: true + EnforcedStyle: bare_percent + SupportedStyles: + - percent_q + - bare_percent +Style/BracesAroundHashParameters: + Description: Enforce braces style around hash parameters. + Enabled: true + EnforcedStyle: no_braces + SupportedStyles: + - braces + - no_braces + - context_dependent +Style/CaseIndentation: + Description: Indentation of when in a case/when/[else/]end. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#indent-when-to-case + Enabled: true + IndentWhenRelativeTo: case + SupportedStyles: + - case + - end + IndentOneStep: false +Style/ClassAndModuleChildren: + Description: Checks style of children classes and modules. + Enabled: false + EnforcedStyle: nested + SupportedStyles: + - nested + - compact +Style/ClassCheck: + Description: Enforces consistent use of `Object#is_a?` or `Object#kind_of?`. + Enabled: true + EnforcedStyle: is_a? + SupportedStyles: + - is_a? + - kind_of? +Style/CollectionMethods: + Description: Preferred collection methods. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#map-find-select-reduce-size + Enabled: true + PreferredMethods: + collect: map + collect!: map! + find: detect + find_all: select + reduce: inject +Style/CommentAnnotation: + Description: Checks formatting of special comments (TODO, FIXME, OPTIMIZE, HACK, + REVIEW). + StyleGuide: https://github.com/bbatsov/ruby-style-guide#annotate-keywords + Enabled: false + Keywords: + - TODO + - FIXME + - OPTIMIZE + - HACK + - REVIEW +Style/DotPosition: + Description: Checks the position of the dot in multi-line method calls. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-multi-line-chains + Enabled: true + EnforcedStyle: trailing + SupportedStyles: + - leading + - trailing +Style/EmptyLineBetweenDefs: + Description: Use empty lines between defs. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#empty-lines-between-methods + Enabled: true + AllowAdjacentOneLineDefs: false +Style/EmptyLinesAroundBlockBody: + Description: Keeps track of empty lines around block bodies. + Enabled: true + EnforcedStyle: no_empty_lines + SupportedStyles: + - empty_lines + - no_empty_lines +Style/EmptyLinesAroundClassBody: + Description: Keeps track of empty lines around class bodies. + Enabled: true + EnforcedStyle: no_empty_lines + SupportedStyles: + - empty_lines + - no_empty_lines +Style/EmptyLinesAroundModuleBody: + Description: Keeps track of empty lines around module bodies. + Enabled: true + EnforcedStyle: no_empty_lines + SupportedStyles: + - empty_lines + - no_empty_lines +Style/Encoding: + Description: Use UTF-8 as the source file encoding. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#utf-8 + Enabled: false + EnforcedStyle: always + SupportedStyles: + - when_needed + - always +Style/FileName: + Description: Use snake_case for source file names. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#snake-case-files + Enabled: false + Exclude: [] +Style/FirstParameterIndentation: + Description: Checks the indentation of the first parameter in a method call. + Enabled: true + EnforcedStyle: special_for_inner_method_call_in_parentheses + SupportedStyles: + - consistent + - special_for_inner_method_call + - special_for_inner_method_call_in_parentheses +Style/For: + Description: Checks use of for or each in multiline loops. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-for-loops + Enabled: true + EnforcedStyle: each + SupportedStyles: + - for + - each +Style/FormatString: + Description: Enforce the use of Kernel#sprintf, Kernel#format or String#%. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#sprintf + Enabled: false + EnforcedStyle: format + SupportedStyles: + - format + - sprintf + - percent +Style/GlobalVars: + Description: Do not introduce global variables. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#instance-vars + Enabled: false + AllowedVariables: [] +Style/GuardClause: + Description: Check for conditionals that can be replaced with guard clauses + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals + Enabled: false + MinBodyLength: 1 +Style/HashSyntax: + Description: 'Prefer Ruby 1.9 hash syntax { a: 1, b: 2 } over 1.8 syntax { :a => + 1, :b => 2 }.' + StyleGuide: https://github.com/bbatsov/ruby-style-guide#hash-literals + Enabled: true + EnforcedStyle: ruby19 + SupportedStyles: + - ruby19 + - hash_rockets +Style/IfUnlessModifier: + Description: Favor modifier if/unless usage when you have a single-line body. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#if-as-a-modifier + Enabled: false + MaxLineLength: 80 +Style/IndentationWidth: + Description: Use 2 spaces for indentation. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-indentation + Enabled: true + Width: 2 +Style/IndentHash: + Description: Checks the indentation of the first key in a hash literal. + Enabled: true + EnforcedStyle: special_inside_parentheses + SupportedStyles: + - special_inside_parentheses + - consistent +Style/LambdaCall: + Description: Use lambda.call(...) instead of lambda.(...). + StyleGuide: https://github.com/bbatsov/ruby-style-guide#proc-call + Enabled: false + EnforcedStyle: call + SupportedStyles: + - call + - braces +Style/Next: + Description: Use `next` to skip iteration instead of a condition at the end. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals + Enabled: false + EnforcedStyle: skip_modifier_ifs + MinBodyLength: 3 + SupportedStyles: + - skip_modifier_ifs + - always +Style/NonNilCheck: + Description: Checks for redundant nil checks. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-non-nil-checks + Enabled: true + IncludeSemanticChanges: false +Style/MethodDefParentheses: + Description: Checks if the method definitions have or don't have parentheses. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#method-parens + Enabled: true + EnforcedStyle: require_parentheses + SupportedStyles: + - require_parentheses + - require_no_parentheses +Style/MethodName: + Description: Use the configured style when naming methods. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#snake-case-symbols-methods-vars + Enabled: true + EnforcedStyle: snake_case + SupportedStyles: + - snake_case + - camelCase +Style/MultilineOperationIndentation: + Description: Checks indentation of binary operations that span more than one line. + Enabled: true + EnforcedStyle: aligned + SupportedStyles: + - aligned + - indented +Style/NumericLiterals: + Description: Add underscores to large numeric literals to improve their readability. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#underscores-in-numerics + Enabled: false + MinDigits: 5 +Style/ParenthesesAroundCondition: + Description: Don't use parentheses around the condition of an if/unless/while. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-parens-if + Enabled: true + AllowSafeAssignment: true +Style/PercentLiteralDelimiters: + Description: Use `%`-literal delimiters consistently + StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-literal-braces + Enabled: false + PreferredDelimiters: + "%": "()" + "%i": "()" + "%q": "()" + "%Q": "()" + "%r": "{}" + "%s": "()" + "%w": "()" + "%W": "()" + "%x": "()" +Style/PercentQLiterals: + Description: Checks if uses of %Q/%q match the configured preference. + Enabled: true + EnforcedStyle: lower_case_q + SupportedStyles: + - lower_case_q + - upper_case_q +Style/PredicateName: + Description: Check the names of predicate methods. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#bool-methods-qmark + Enabled: true + NamePrefix: + - is_ + - has_ + - have_ + NamePrefixBlacklist: + - is_ +Style/RaiseArgs: + Description: Checks the arguments passed to raise/fail. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#exception-class-messages + Enabled: false + EnforcedStyle: exploded + SupportedStyles: + - compact + - exploded +Style/RedundantReturn: + Description: Don't use return where it's not required. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-explicit-return + Enabled: true + AllowMultipleReturnValues: false +Style/RegexpLiteral: + Description: Use %r for regular expressions matching more than `MaxSlashes` '/' + characters. Use %r only for regular expressions matching more than `MaxSlashes` + '/' character. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-r + Enabled: false + MaxSlashes: 1 +Style/Semicolon: + Description: Don't use semicolons to terminate expressions. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-semicolon + Enabled: true + AllowAsExpressionSeparator: false +Style/SignalException: + Description: Checks for proper usage of fail and raise. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#fail-method + Enabled: false + EnforcedStyle: semantic + SupportedStyles: + - only_raise + - only_fail + - semantic +Style/SingleLineBlockParams: + Description: Enforces the names of some block params. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#reduce-blocks + Enabled: false + Methods: + - reduce: + - a + - e + - inject: + - a + - e +Style/SingleLineMethods: + Description: Avoid single-line methods. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-single-line-methods + Enabled: false + AllowIfMethodIsEmpty: true +Style/StringLiterals: + Description: Checks if uses of quotes match the configured preference. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-string-literals + Enabled: true + EnforcedStyle: double_quotes + SupportedStyles: + - single_quotes + - double_quotes +Style/StringLiteralsInInterpolation: + Description: Checks if uses of quotes inside expressions in interpolated strings + match the configured preference. + Enabled: true + EnforcedStyle: single_quotes + SupportedStyles: + - single_quotes + - double_quotes +Style/SpaceAroundBlockParameters: + Description: Checks the spacing inside and after block parameters pipes. + Enabled: true + EnforcedStyleInsidePipes: no_space + SupportedStyles: + - space + - no_space +Style/SpaceAroundEqualsInParameterDefault: + Description: Checks that the equals signs in parameter default assignments have + or don't have surrounding space depending on configuration. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-around-equals + Enabled: true + EnforcedStyle: space + SupportedStyles: + - space + - no_space +Style/SpaceBeforeBlockBraces: + Description: Checks that the left block brace has or doesn't have space before it. + Enabled: true + EnforcedStyle: space + SupportedStyles: + - space + - no_space +Style/SpaceInsideBlockBraces: + Description: Checks that block braces have or don't have surrounding space. For + blocks taking parameters, checks that the left brace has or doesn't have trailing + space. + Enabled: true + EnforcedStyle: space + SupportedStyles: + - space + - no_space + EnforcedStyleForEmptyBraces: no_space + SpaceBeforeBlockParameters: true +Style/SpaceInsideHashLiteralBraces: + Description: Use spaces inside hash literal braces - or don't. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-operators + Enabled: true + EnforcedStyle: space + EnforcedStyleForEmptyBraces: no_space + SupportedStyles: + - space + - no_space +Style/SymbolProc: + Description: Use symbols as procs instead of blocks when possible. + Enabled: true + IgnoredMethods: + - respond_to +Style/TrailingBlankLines: + Description: Checks trailing blank lines and final newline. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#newline-eof + Enabled: true + EnforcedStyle: final_newline + SupportedStyles: + - final_newline + - final_blank_line +Style/TrailingCommaInLiteral: + Description: Checks for trailing comma in parameter lists and literals. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas + Enabled: false + EnforcedStyleForMultiline: no_comma + SupportedStyles: + - comma + - no_comma +Style/TrivialAccessors: + Description: Prefer attr_* methods to trivial readers/writers. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#attr_family + Enabled: false + ExactNameMatch: false + AllowPredicates: false + AllowDSLWriters: false + Whitelist: + - to_ary + - to_a + - to_c + - to_enum + - to_h + - to_hash + - to_i + - to_int + - to_io + - to_open + - to_path + - to_proc + - to_r + - to_regexp + - to_str + - to_s + - to_sym +Style/VariableName: + Description: Use the configured style when naming variables. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#snake-case-symbols-methods-vars + Enabled: true + EnforcedStyle: snake_case + SupportedStyles: + - snake_case + - camelCase +Style/WhileUntilModifier: + Description: Favor modifier while/until usage when you have a single-line body. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#while-as-a-modifier + Enabled: false + MaxLineLength: 80 +Style/WordArray: + Description: Use %w or %W for arrays of words. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-w + Enabled: false + MinSize: 0 + WordRegex: !ruby/regexp /\A[\p{Word}]+\z/ +Metrics/AbcSize: + Description: A calculated magnitude based on number of assignments, branches, and + conditions. + Enabled: true + Max: 15 +Metrics/BlockNesting: + Description: Avoid excessive block nesting + StyleGuide: https://github.com/bbatsov/ruby-style-guide#three-is-the-number-thou-shalt-count + Enabled: false + Max: 3 +Metrics/ClassLength: + Description: Avoid classes longer than 100 lines of code. + Enabled: false + CountComments: false + Max: 100 +Metrics/CyclomaticComplexity: + Description: A complexity metric that is strongly correlated to the number of test + cases needed to validate a method. + Enabled: false + Max: 6 +Metrics/LineLength: + Description: Limit lines to 80 characters. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#80-character-limits + Enabled: true + Max: 80 + AllowURI: true + URISchemes: + - http + - https +Metrics/MethodLength: + Description: Avoid methods longer than 10 lines of code. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#short-methods + Enabled: false + CountComments: false + Max: 10 +Metrics/ParameterLists: + Description: Avoid parameter lists longer than three or four parameters. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#too-many-params + Enabled: false + Max: 5 + CountKeywordArgs: true +Metrics/PerceivedComplexity: + Description: A complexity metric geared towards measuring complexity for a human + reader. + Enabled: false + Max: 7 +Lint/AssignmentInCondition: + Description: Don't use assignment in conditions. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#safe-assignment-in-condition + Enabled: false + AllowSafeAssignment: true +Lint/EndAlignment: + Description: Align ends correctly. + Enabled: true + AlignWith: keyword + SupportedStyles: + - keyword + - variable +Lint/DefEndAlignment: + Description: Align ends corresponding to defs correctly. + Enabled: true + AlignWith: start_of_line + SupportedStyles: + - start_of_line + - def +Rails/ActionFilter: + Description: Enforces consistent use of action filter methods. + Enabled: false + EnforcedStyle: action + SupportedStyles: + - action + - filter + Include: + - app/controllers/**/*.rb +Rails/HasAndBelongsToMany: + Description: Prefer has_many :through to has_and_belongs_to_many. + Enabled: true + Include: + - app/models/**/*.rb +Rails/Output: + Description: Checks for calls to puts, print, etc. + Enabled: true + Include: + - app/**/*.rb + - config/**/*.rb + - db/**/*.rb + - lib/**/*.rb +Rails/ReadWriteAttribute: + Description: Checks for read_attribute(:attr) and write_attribute(:attr, val). + Enabled: true + Include: + - app/models/**/*.rb +Rails/ScopeArgs: + Description: Checks the arguments of ActiveRecord scopes. + Enabled: true + Include: + - app/models/**/*.rb +Rails/Validation: + Description: Use validates :attribute, hash of validations. + Enabled: true + Include: + - app/models/**/*.rb +Style/InlineComment: + Description: Avoid inline comments. + Enabled: false +Style/MethodCalledOnDoEndBlock: + Description: Avoid chaining a method call on a do...end block. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#single-line-blocks + Enabled: false +Style/SymbolArray: + Description: Use %i or %I for arrays of symbols. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-i + Enabled: false +Style/ExtraSpacing: + Description: Do not use unnecessary spacing. + Enabled: true +Style/AccessorMethodName: + Description: Check the naming of accessor methods for get_/set_. + Enabled: false +Style/Alias: + Description: Use alias_method instead of alias. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#alias-method + Enabled: false +Style/AlignArray: + Description: Align the elements of an array literal if they span more than one line. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#align-multiline-arrays + Enabled: true +Style/ArrayJoin: + Description: Use Array#join instead of Array#*. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#array-join + Enabled: false +Style/AsciiComments: + Description: Use only ascii symbols in comments. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#english-comments + Enabled: false +Style/AsciiIdentifiers: + Description: Use only ascii symbols in identifiers. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#english-identifiers + Enabled: false +Style/Attr: + Description: Checks for uses of Module#attr. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#attr + Enabled: false +Style/BeginBlock: + Description: Avoid the use of BEGIN blocks. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-BEGIN-blocks + Enabled: true +Style/BlockComments: + Description: Do not use block comments. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-block-comments + Enabled: true +Style/BlockEndNewline: + Description: Put end statement of multiline block on its own line. + Enabled: true +Style/Blocks: + Description: Avoid using {...} for multi-line blocks (multiline chaining is always + ugly). Prefer {...} over do...end for single-line blocks. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#single-line-blocks + Enabled: true +Style/CaseEquality: + Description: Avoid explicit use of the case equality operator(===). + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-case-equality + Enabled: false +Style/CharacterLiteral: + Description: Checks for uses of character literals. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-character-literals + Enabled: false +Style/ClassAndModuleCamelCase: + Description: Use CamelCase for classes and modules. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#camelcase-classes + Enabled: true +Style/ClassMethods: + Description: Use self when defining module/class methods. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#def-self-singletons + Enabled: true +Style/ClassVars: + Description: Avoid the use of class variables. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-class-vars + Enabled: false +Style/ColonMethodCall: + Description: 'Do not use :: for method call.' + StyleGuide: https://github.com/bbatsov/ruby-style-guide#double-colons + Enabled: false +Style/CommentIndentation: + Description: Indentation of comments. + Enabled: true +Style/ConstantName: + Description: Constants should use SCREAMING_SNAKE_CASE. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#screaming-snake-case + Enabled: true +Style/DefWithParentheses: + Description: Use def with parentheses when there are arguments. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#method-parens + Enabled: true +Style/Documentation: + Description: Document classes and non-namespace modules. + Enabled: false +Style/DoubleNegation: + Description: Checks for uses of double negation (!!). + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-bang-bang + Enabled: false +Style/EachWithObject: + Description: Prefer `each_with_object` over `inject` or `reduce`. + Enabled: false +Style/ElseAlignment: + Description: Align elses and elsifs correctly. + Enabled: true +Style/EmptyElse: + Description: Avoid empty else-clauses. + Enabled: true +Style/EmptyLines: + Description: Don't use several empty lines in a row. + Enabled: true +Style/EmptyLinesAroundAccessModifier: + Description: Keep blank lines around access modifiers. + Enabled: true +Style/EmptyLinesAroundMethodBody: + Description: Keeps track of empty lines around method bodies. + Enabled: true +Style/EmptyLiteral: + Description: Prefer literals to Array.new/Hash.new/String.new. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#literal-array-hash + Enabled: false +Style/EndBlock: + Description: Avoid the use of END blocks. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-END-blocks + Enabled: true +Style/EndOfLine: + Description: Use Unix-style line endings. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#crlf + Enabled: true +Style/EvenOdd: + Description: Favor the use of Fixnum#even? && Fixnum#odd? + StyleGuide: https://github.com/bbatsov/ruby-style-guide#predicate-methods + Enabled: false +Style/FlipFlop: + Description: Checks for flip flops + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-flip-flops + Enabled: false +Style/IfWithSemicolon: + Description: Do not use if x; .... Use the ternary operator instead. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-semicolon-ifs + Enabled: false +Style/IndentationConsistency: + Description: Keep indentation straight. + Enabled: true +Style/IndentArray: + Description: Checks the indentation of the first element in an array literal. + Enabled: true +Style/InfiniteLoop: + Description: Use Kernel#loop for infinite loops. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#infinite-loop + Enabled: true +Style/Lambda: + Description: Use the new lambda literal syntax for single-line blocks. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#lambda-multi-line + Enabled: false +Style/LeadingCommentSpace: + Description: Comments should start with a space. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#hash-space + Enabled: true +Style/LineEndConcatenation: + Description: Use \ instead of + or << to concatenate two string literals at line + end. + Enabled: false +Style/MethodCallParentheses: + Description: Do not use parentheses for method calls with no arguments. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-args-no-parens + Enabled: true +Style/ModuleFunction: + Description: Checks for usage of `extend self` in modules. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#module-function + Enabled: false +Style/MultilineBlockChain: + Description: Avoid multi-line chains of blocks. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#single-line-blocks + Enabled: true +Style/MultilineBlockLayout: + Description: Ensures newlines after multiline block do statements. + Enabled: true +Style/MultilineIfThen: + Description: Do not use then for multi-line if/unless. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-then + Enabled: true +Style/MultilineTernaryOperator: + Description: 'Avoid multi-line ?: (the ternary operator); use if/unless instead.' + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-multiline-ternary + Enabled: true +Style/NegatedIf: + Description: Favor unless over if for negative conditions (or control flow or). + StyleGuide: https://github.com/bbatsov/ruby-style-guide#unless-for-negatives + Enabled: false +Style/NegatedWhile: + Description: Favor until over while for negative conditions. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#until-for-negatives + Enabled: false +Style/NestedTernaryOperator: + Description: Use one expression per branch in a ternary operator. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-nested-ternary + Enabled: true +Style/NilComparison: + Description: Prefer x.nil? to x == nil. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#predicate-methods + Enabled: false +Style/Not: + Description: Use ! instead of not. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#bang-not-not + Enabled: false +Style/OneLineConditional: + Description: Favor the ternary operator(?:) over if/then/else/end constructs. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#ternary-operator + Enabled: false +Style/OpMethod: + Description: When defining binary operators, name the argument other. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#other-arg + Enabled: false +Style/PerlBackrefs: + Description: Avoid Perl-style regex back references. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-perl-regexp-last-matchers + Enabled: false +Style/Proc: + Description: Use proc instead of Proc.new. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#proc + Enabled: false +Style/RedundantBegin: + Description: Don't use begin blocks when they are not needed. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#begin-implicit + Enabled: true +Style/RedundantException: + Description: Checks for an obsolete RuntimeException argument in raise/fail. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-explicit-runtimeerror + Enabled: true +Style/RedundantSelf: + Description: Don't use self where it's not needed. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-self-unless-required + Enabled: true +Style/RescueModifier: + Description: Avoid using rescue in its modifier form. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-rescue-modifiers + Enabled: true +Style/SelfAssignment: + Description: Checks for places where self-assignment shorthand should have been + used. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#self-assignment + Enabled: false +Style/SpaceBeforeFirstArg: + Description: Checks that exactly one space is used between a method name and the + first argument for method calls without parentheses. + Enabled: true +Style/SpaceAfterColon: + Description: Use spaces after colons. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-operators + Enabled: true +Style/SpaceAfterComma: + Description: Use spaces after commas. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-operators + Enabled: true +Style/SpaceAroundKeyword: + Description: Use spaces after if/elsif/unless/while/until/case/when. + Enabled: true +Style/SpaceAfterMethodName: + Description: Do not put a space between a method name and the opening parenthesis + in a method definition. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#parens-no-spaces + Enabled: true +Style/SpaceAfterNot: + Description: Tracks redundant space after the ! operator. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-space-bang + Enabled: true +Style/SpaceAfterSemicolon: + Description: Use spaces after semicolons. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-operators + Enabled: true +Style/SpaceBeforeComma: + Description: No spaces before commas. + Enabled: true +Style/SpaceBeforeComment: + Description: Checks for missing space between code and a comment on the same line. + Enabled: true +Style/SpaceBeforeSemicolon: + Description: No spaces before semicolons. + Enabled: true +Style/SpaceAroundOperators: + Description: Use spaces around operators. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-operators + Enabled: true +Style/SpaceInsideBrackets: + Description: No spaces after [ or before ]. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-spaces-braces + Enabled: true +Style/SpaceInsideParens: + Description: No spaces after ( or before ). + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-spaces-braces + Enabled: true +Style/SpaceInsideRangeLiteral: + Description: No spaces inside range literals. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-space-inside-range-literals + Enabled: true +Style/SpecialGlobalVars: + Description: Avoid Perl-style global variables. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-cryptic-perlisms + Enabled: false +Style/StructInheritance: + Description: Checks for inheritance from Struct.new. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-extend-struct-new + Enabled: true +Style/Tab: + Description: No hard tabs. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#spaces-indentation + Enabled: true +Style/TrailingWhitespace: + Description: Avoid trailing whitespace. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-trailing-whitespace + Enabled: true +Style/UnlessElse: + Description: Do not use unless with else. Rewrite these with the positive case first. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-else-with-unless + Enabled: true +Style/UnneededCapitalW: + Description: Checks for %W when interpolation is not needed. + Enabled: true +Style/UnneededPercentQ: + Description: Checks for %q/%Q when single quotes or double quotes would do. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-q + Enabled: true +Style/UnneededPercentX: + Description: Checks for %x when `` would do. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#percent-x + Enabled: true +Style/VariableInterpolation: + Description: Don't interpolate global, instance and class variables directly in + strings. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#curlies-interpolate + Enabled: false +Style/WhenThen: + Description: Use when x then ... for one-line cases. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#one-line-cases + Enabled: false +Style/WhileUntilDo: + Description: Checks for redundant do after while or until. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-multiline-while-do + Enabled: true +Lint/AmbiguousOperator: + Description: Checks for ambiguous operators in the first argument of a method invocation + without parentheses. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#parens-as-args + Enabled: false +Lint/AmbiguousRegexpLiteral: + Description: Checks for ambiguous regexp literals in the first argument of a method + invocation without parenthesis. + Enabled: false +Lint/BlockAlignment: + Description: Align block ends correctly. + Enabled: true +Lint/ConditionPosition: + Description: Checks for condition placed in a confusing position relative to the + keyword. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#same-line-condition + Enabled: false +Lint/Debugger: + Description: Check for debugger calls. + Enabled: true +Lint/DeprecatedClassMethods: + Description: Check for deprecated class method calls. + Enabled: false +Lint/DuplicateMethods: + Description: Check for duplicate methods calls. + Enabled: true +Lint/ElseLayout: + Description: Check for odd code arrangement in an else block. + Enabled: false +Lint/EmptyEnsure: + Description: Checks for empty ensure block. + Enabled: true +Lint/EmptyInterpolation: + Description: Checks for empty string interpolation. + Enabled: true +Lint/EndInMethod: + Description: END blocks should not be placed inside method definitions. + Enabled: true +Lint/EnsureReturn: + Description: Do not use return in an ensure block. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-return-ensure + Enabled: true +Lint/Eval: + Description: The use of eval represents a serious security risk. + Enabled: true +Lint/HandleExceptions: + Description: Don't suppress exception. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#dont-hide-exceptions + Enabled: false +Lint/InvalidCharacterLiteral: + Description: Checks for invalid character literals with a non-escaped whitespace + character. + Enabled: false +Lint/LiteralInCondition: + Description: Checks of literals used in conditions. + Enabled: false +Lint/LiteralInInterpolation: + Description: Checks for literals used in interpolation. + Enabled: false +Lint/Loop: + Description: Use Kernel#loop with break rather than begin/end/until or begin/end/while + for post-loop tests. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#loop-with-break + Enabled: false +Lint/ParenthesesAsGroupedExpression: + Description: Checks for method calls with a space before the opening parenthesis. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#parens-no-spaces + Enabled: false +Lint/RequireParentheses: + Description: Use parentheses in the method call to avoid confusion about precedence. + Enabled: false +Lint/RescueException: + Description: Avoid rescuing the Exception class. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-blind-rescues + Enabled: true +Lint/ShadowingOuterLocalVariable: + Description: Do not use the same name as outer local variable for block arguments + or block local variables. + Enabled: true +Lint/SpaceBeforeFirstArg: + Description: Put a space between a method name and the first argument in a method + call without parentheses. + Enabled: true +Lint/StringConversionInInterpolation: + Description: Checks for Object#to_s usage in string interpolation. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-to-s + Enabled: true +Lint/UnderscorePrefixedVariableName: + Description: Do not use prefix `_` for a variable that is used. + Enabled: false +Lint/UnusedBlockArgument: + Description: Checks for unused block arguments. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#underscore-unused-vars + Enabled: true +Lint/UnusedMethodArgument: + Description: Checks for unused method arguments. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#underscore-unused-vars + Enabled: true +Lint/UnreachableCode: + Description: Unreachable code. + Enabled: true +Lint/UselessAccessModifier: + Description: Checks for useless access modifiers. + Enabled: true +Lint/UselessAssignment: + Description: Checks for useless assignment to a local variable. + StyleGuide: https://github.com/bbatsov/ruby-style-guide#underscore-unused-vars + Enabled: true +Lint/UselessComparison: + Description: Checks for comparison of something with itself. + Enabled: true +Lint/UselessElseWithoutRescue: + Description: Checks for useless `else` in `begin..end` without `rescue`. + Enabled: true +Lint/UselessSetterCall: + Description: Checks for useless setter call to a local variable. + Enabled: true +Lint/Void: + Description: Possible use of operator/literal/variable in void context. + Enabled: false +Rails/Delegate: + Description: Prefer delegate method for delegations. + Enabled: false + diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..6539ab9ca --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1 @@ +inherit_from: .hound.yml diff --git a/.travis.yml b/.travis.yml index 6c309b0c3..4c21dd2cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,25 @@ +language: ruby +sudo: false + rvm: - - 1.8.7 - - 1.9.2 - - ree - - rbx-18mode + - 2.1 + - 2.2 + - 2.3 + - 2.4 + +script: "bundle exec rake clean spec cucumber" -before_script: "sudo ntpdate -ub ntp.ubuntu.com pool.ntp.org; true" -script: "bundle exec rake clean test cucumber" +addons: + apt: + packages: + - ghostscript gemfile: - - gemfiles/rails2.gemfile - - gemfiles/rails3.gemfile - - gemfiles/rails3_1.gemfile - - gemfiles/rails3_2.gemfile + - gemfiles/4.2.gemfile + - gemfiles/5.0.gemfile + +matrix: + fast_finish: true + exclude: + - gemfile: gemfiles/5.0.gemfile + rvm: 2.1 diff --git a/Appraisals b/Appraisals index 55ccd056c..18516dac2 100644 --- a/Appraisals +++ b/Appraisals @@ -1,20 +1,7 @@ -appraise "rails2" do - gem "rails", "~> 2.3.14" - gem "paperclip", :path => "../" +appraise "4.2" do + gem "rails", "~> 4.2.0" end -appraise "rails3" do - gem "rails", "~> 3.0.10" - gem "paperclip", :path => "../" +appraise "5.0" do + gem "rails", "~> 5.0.0" end - -appraise "rails3_1" do - gem "rails", "~> 3.1.0" - gem "paperclip", :path => "../" -end - -appraise "rails3_2" do - gem "rails", "~> 3.2.0" - gem "paperclip", :path => "../" -end - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31c4904bc..e5f13901f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,22 +1,36 @@ -We love pull requests. Here's a quick guide: +Contributing +============ + +We love pull requests from everyone. By participating in this project, you agree +to abide by the thoughtbot [code of conduct]. + +[code of conduct]: https://thoughtbot.com/open-source-code-of-conduct + +Here's a quick guide for contributing: 1. Fork the repo. -2. Run the tests. We only take pull requests with passing tests, and it's great -to know that you have a clean slate: `bundle && rake` +1. Make sure you have ImageMagick and Ghostscript installed. See [this section] +(./README.md#image-processor) of the README. + +1. Run the tests. We only take pull requests with passing tests, and it's great +to know that you have a clean slate: `bundle && bundle exec rake` -3. Add a test for your change. Only refactoring and documentation changes +1. Add a test for your change. Only refactoring and documentation changes require no new tests. If you are adding functionality or fixing a bug, we need a test! -4. Make the test pass. +1. Make the test pass. -5. Push to your fork and submit a pull request. +1. Mention how your changes affect the project to other developers and users in + the `NEWS.md` file. +1. Push to your fork and submit a pull request. At this point you're waiting on us. We like to at least comment on, if not -accept, pull requests within three business days (and, typically, one business -day). We may suggest some changes or improvements or alternatives. +accept, pull requests within seven business days (most of the work on Paperclip +gets done on Fridays). We may suggest some changes or improvements or +alternatives. Some things that will increase the chance that your pull request is accepted, taken straight from the Ruby on Rails guide: @@ -26,7 +40,41 @@ taken straight from the Ruby on Rails guide: * Update the documentation, the surrounding one, examples elsewhere, guides, whatever is affected by your contribution -Syntax: +Running Tests +------------- + +Paperclip uses [Appraisal](https://github.com/thoughtbot/appraisal) to aid +testing against multiple version of Ruby on Rails. This helps us to make sure +that Paperclip performs correctly with them. + +Paperclip also uses [RSpec](http://rspec.info) for its unit tests. If you submit +tests that are not written for Cucumber or RSpec without a very good reason, you +will be asked to rewrite them before we'll accept. + +### Bootstrapping your test suite: + + bundle install + bundle exec appraisal install + +This will install all the required gems that requires to test against each +version of Rails, which defined in `gemfiles/*.gemfile`. + +### To run a full test suite: + + bundle exec appraisal rake + +This will run RSpec and Cucumber against all version of Rails + +### To run single Test::Unit or Cucumber test + +You need to specify a `BUNDLE_GEMFILE` pointing to the gemfile before running +the normal test command: + + BUNDLE_GEMFILE=gemfiles/4.1.gemfile rspec spec/paperclip/attachment_spec.rb + BUNDLE_GEMFILE=gemfiles/4.1.gemfile cucumber features/basic_integration.feature + +Syntax +------ * Two spaces, no tabs. * No trailing whitespace. Blank lines should not have any space. diff --git a/Gemfile b/Gemfile index 3a936dc1b..9de7c7440 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,16 @@ -source "http://rubygems.org" +source "https://rubygems.org" gemspec -gem "jruby-openssl", :platform => :jruby +gem 'sqlite3', '~> 1.3.8', :platforms => :ruby +gem 'pry' + +# Hinting at development dependencies +# Prevents bundler from taking a long-time to resolve +group :development, :test do + gem 'activerecord-import' + gem 'mime-types' + gem 'builder' + gem 'rubocop', require: false + gem 'rspec' +end diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 1fcd104f2..000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,137 +0,0 @@ -PATH - remote: . - specs: - paperclip (2.7.0) - activerecord (>= 2.3.0) - activesupport (>= 2.3.2) - cocaine (>= 0.0.2) - mime-types - -GEM - remote: http://rubygems.org/ - specs: - activemodel (3.2.1) - activesupport (= 3.2.1) - builder (~> 3.0.0) - activerecord (3.2.1) - activemodel (= 3.2.1) - activesupport (= 3.2.1) - arel (~> 3.0.0) - tzinfo (~> 0.3.29) - activesupport (3.2.1) - i18n (~> 0.6) - multi_json (~> 1.0) - appraisal (0.4.1) - bundler - rake - arel (3.0.2) - aruba (0.4.6) - bcat (>= 0.6.1) - childprocess (>= 0.2.0) - cucumber (>= 1.0.2) - rdiscount (>= 1.6.8) - rspec (>= 2.6.0) - aws-sdk (1.3.4) - httparty (~> 0.7) - json (~> 1.4) - nokogiri (>= 1.4.4) - uuidtools (~> 2.1) - bcat (0.6.2) - rack (~> 1.0) - builder (3.0.0) - capybara (1.1.1) - mime-types (>= 1.16) - nokogiri (>= 1.3.3) - rack (>= 1.0.0) - rack-test (>= 0.5.4) - selenium-webdriver (~> 2.0) - xpath (~> 0.1.4) - childprocess (0.2.2) - ffi (~> 1.0.6) - cocaine (0.2.0) - cucumber (1.1.4) - builder (>= 2.1.2) - diff-lcs (>= 1.1.2) - gherkin (~> 2.7.1) - json (>= 1.4.6) - term-ansicolor (>= 1.0.6) - diff-lcs (1.1.3) - excon (0.6.6) - fakeweb (1.3.0) - ffi (1.0.9) - fog (0.11.0) - builder - excon (~> 0.6.5) - formatador (~> 0.2.0) - mime-types - multi_json (~> 1.0.3) - net-scp (~> 1.0.4) - net-ssh (~> 2.1.4) - nokogiri (~> 1.5.0) - ruby-hmac - formatador (0.2.1) - gherkin (2.7.1) - json (>= 1.4.6) - httparty (0.8.1) - multi_json - multi_xml - i18n (0.6.0) - json (1.6.5) - json_pure (1.6.1) - metaclass (0.0.1) - mime-types (1.16) - mocha (0.10.0) - metaclass (~> 0.0.1) - multi_json (1.0.4) - multi_xml (0.4.1) - net-scp (1.0.4) - net-ssh (>= 1.99.1) - net-ssh (2.1.4) - nokogiri (1.5.0) - rack (1.3.3) - rack-test (0.6.1) - rack (>= 1.0) - rake (0.9.2.2) - rdiscount (1.6.8) - rspec (2.6.0) - rspec-core (~> 2.6.0) - rspec-expectations (~> 2.6.0) - rspec-mocks (~> 2.6.0) - rspec-core (2.6.4) - rspec-expectations (2.6.0) - diff-lcs (~> 1.1.2) - rspec-mocks (2.6.0) - ruby-hmac (0.4.0) - rubyzip (0.9.4) - selenium-webdriver (2.7.0) - childprocess (>= 0.2.1) - ffi (>= 1.0.7) - json_pure - rubyzip - shoulda (2.11.3) - sqlite3 (1.3.4) - term-ansicolor (1.0.7) - tzinfo (0.3.31) - uuidtools (2.1.2) - xpath (0.1.4) - nokogiri (~> 1.3) - -PLATFORMS - ruby - -DEPENDENCIES - appraisal (~> 0.4.0) - aruba - aws-sdk - bundler - capybara - cocaine (~> 0.2) - cucumber (~> 1.1.0) - fakeweb - fog - jruby-openssl - mocha - paperclip! - rake - shoulda - sqlite3 (~> 1.3.4) diff --git a/LICENSE b/LICENSE index 299b9ed6a..68bcce33f 100644 --- a/LICENSE +++ b/LICENSE @@ -3,7 +3,7 @@ LICENSE The MIT License -Copyright (c) 2008 Jon Yurek and thoughtbot, inc. +Copyright (c) 2008-2016 Jon Yurek and thoughtbot, inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,5 +22,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - diff --git a/MIGRATING-ES.md b/MIGRATING-ES.md new file mode 100644 index 000000000..fa0bfd40c --- /dev/null +++ b/MIGRATING-ES.md @@ -0,0 +1,317 @@ +# Migrando de Paperclip a ActiveStorage + +Paperclip y ActiveStorage resuelven problemas similares con soluciones +similares, por lo que pasar de uno a otro es simple. + +El proceso de ir desde Paperclip hacia ActiveStorage es como sigue: + +1. Implementa las migraciones a la base de datos de ActiveStorage. +2. Configura el almacenamiento. +3. Copia la base de datos. +4. Copia los archivos. +5. Actualiza tus pruebas. +6. Actualiza tus vistas. +7. Actualiza tus controladores. +8. Actualiza tus modelos. + +## Implementa las migraciones a la base de datos de ActiveStorage + +Sigue [las instrucciones para instalar ActiveStorage]. Muy probablemente vas a +querer agregar la gema `mini_magick` a tu Gemfile. + + +```sh +rails active_storage:install +``` + +[las instrucciones para instalar ActiveStorage]: https://github.com/rails/rails/blob/master/activestorage/README.md#installation + +## Configura el almacenamiento + +De nuevo, sigue [las instrucciones para configurar ActiveStorage]. + +[las instrucciones para configurar ActiveStorage]: http://edgeguides.rubyonrails.org/active_storage_overview.html#setup + +## Copia la base de datos. + +Las tablas `active_storage_blobs` y`active_storage_attachments` son en donde +ActiveStorage espera encontrar los metadatos del archivo. Paperclip almacena los +metadatos del archivo directamente en en la tabla del objeto asociado. + +Vas a necesitar escribir una migración para esta conversión. Proveer un script +simple, es complicado porque están involucrados tus modelos. ¡Pero lo +intentaremos! + +Así sería para un `User` con un `avatar` en Paperclip: + +```ruby +class User < ApplicationRecord + has_attached_file :avatar +end +``` + +Tus migraciones de Paperclip producirán una tabla como la siguiente: + +```ruby +create_table "users", force: :cascade do |t| + t.string "avatar_file_name" + t.string "avatar_content_type" + t.integer "avatar_file_size" + t.datetime "avatar_updated_at" +end +``` + +Y tu la convertirás en estas tablas: + +```ruby +create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.integer "record_id", null: false + t.integer "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true +end +``` + +```ruby +create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true +end +``` + +Así que asumiendo que quieres dejar los archivos en el mismo lugar, _esta es tu +migración_. De otra forma, ve la siguiente sección primero y modifica la +migración como corresponda. + +```ruby +Dir[Rails.root.join("app/models/**/*.rb")].sort.each { |file| require file } + +class ConvertToActiveStorage < ActiveRecord::Migration[5.2] + require 'open-uri' + + def up + # postgres + get_blob_id = 'LASTVAL()' + # mariadb + # get_blob_id = 'LAST_INSERT_ID()' + # sqlite + # get_blob_id = 'LAST_INSERT_ROWID()' + + active_storage_blob_statement = ActiveRecord::Base.connection.raw_connection.prepare(<<-SQL) + INSERT INTO active_storage_blobs ( + key, filename, content_type, metadata, byte_size, + checksum, created_at + ) VALUES (?, ?, ?, '{}', ?, ?, ?) + SQL + + active_storage_attachment_statement = ActiveRecord::Base.connection.raw_connection.prepare(<<-SQL) + INSERT INTO active_storage_attachments ( + name, record_type, record_id, blob_id, created_at + ) VALUES (?, ?, ?, #{get_blob_id}, ?) + SQL + + models = ActiveRecord::Base.descendants.reject(&:abstract_class?) + + transaction do + models.each do |model| + attachments = model.column_names.map do |c| + if c =~ /(.+)_file_name$/ + $1 + end + end.compact + + model.find_each.each do |instance| + attachments.each do |attachment| + active_storage_blob_statement.execute( + key(instance, attachment), + instance.send("#{attachment}_file_name"), + instance.send("#{attachment}_content_type"), + instance.send("#{attachment}_file_size"), + checksum(instance.send(attachment)), + instance.updated_at.iso8601 + ) + + active_storage_attachment_statement. + execute(attachment, model.name, instance.id, instance.updated_at.iso8601) + end + end + end + end + + active_storage_attachment_statement.close + active_storage_blob_statement.close + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + def key(instance, attachment) + SecureRandom.uuid + # Alternativamente: + # instance.send("#{attachment}_file_name") + end + + def checksum(attachment) + # archivos locales almacenados en disco: + url = attachment.path + Digest::MD5.base64digest(File.read(url)) + + # archivos remotos almacenados en la computadora de alguién más: + # url = attachment.url + # Digest::MD5.base64digest(Net::HTTP.get(URI(url))) + end +end +``` + +## Copia los archivos + +La migración de arriba deja los archivos como estaban. Sin embargo, +los servicios de Paperclip y ActiveStorage utilizan diferentes ubicaciones. + +Por defecto, Paperclip se ve así: + +``` +public/system/users/avatars/000/000/004/original/the-mystery-of-life.png +``` + +Y ActiveStorage se ve así: + +``` +storage/xM/RX/xMRXuT6nqpoiConJFQJFt6c9 +``` + +Ese `xMRXuT6nqpoiConJFQJFt6c9` es el valor de `active_storage_blobs.key`. En la +migración de arriba usamos simplemente el nombre del archivo, pero tal vez +quieras usar un UUID. + +Migrando los archivos en un hospedaje externo (S3, Azure Storage, GCS, etc.) +está fuera del alcance de este documento inicial. Así es como se vería para un +almacenamiento local: + +```ruby +#!bin/rails runner + +class ActiveStorageBlob < ActiveRecord::Base +end + +class ActiveStorageAttachment < ActiveRecord::Base + belongs_to :blob, class_name: 'ActiveStorageBlob' + belongs_to :record, polymorphic: true +end + +ActiveStorageAttachment.find_each do |attachment| + name = attachment.name + + source = attachment.record.send(name).path + dest_dir = File.join( + "storage", + attachment.blob.key.first(2), + attachment.blob.key.first(4).last(2)) + dest = File.join(dest_dir, attachment.blob.key) + + FileUtils.mkdir_p(dest_dir) + puts "Moving #{source} to #{dest}" + FileUtils.cp(source, dest) +end +``` + +## Actualiza tus pruebas + +En lugar de utilizar `have_attached_file`, será necesario que escribas tu propio +matcher. Aquí hay un matcher similar _en espíritu_ al que Paperclip provee: + + +```ruby +RSpec::Matchers.define :have_attached_file do |name| + matches do |record| + file = record.send(name) + file.respond_to?(:variant) && file.respond_to?(:attach) + end +end +``` + +## Actualiza tus vistas + +En Paperclip se ven así: + +```ruby +image_tag @user.avatar.url(:medium) +``` + +En ActiveStorage se ven así: + +```ruby +image_tag @user.avatar.variant(resize: "250x250") +``` + +## Actualiza tus controladores + +Esto no debería _requerir_ ningúna actualización. Sin embargo, si te fijas en +el schema de tu base de datos, notaras un join. + +Por ejemplo si tu controlador tiene: + +```ruby +def index + @users = User.all.order(:name) +end +``` + +Y tu vista tiene: + +``` + +``` + +Vas a terminar con un n+1, ya que descargas cada archivo adjunto dentro del +bucle. + +Así que mientras que el controlador y el modelo funcionarán sin ningún cambio, +tal vez quieras revisar dos veces tus bucles y agregar `includes` en dónde haga +falta. + +ActiveStorage agrega `avatar_attachment` y `avatar_blob` a las relaciones del +tipo `has-one`, así como `avatar_attachments` y `avatar_blobs` a las relaciones +de tipo `has-many`: + +```ruby +def index + @users = User.all.order(:name).includes(:avatar_attachment) +end +``` + +## Actualiza tus modelos + +Sigue [la guía sobre cómo adjuntar archivos a los registros]. Por ejemplo, un +`User` con un `avatar` se representa como: + +```ruby +class User < ApplicationRecord + has_one_attached :avatar +end +``` + +Cualquier cambio de tamaño se hace en la vista como un `variant`. + +[la guía sobre cómo adjuntar archivos a los registros]: http://edgeguides.rubyonrails.org/active_storage_overview.html#attaching-files-to-records + +## Quita Paperclip + +Quita la gema de tu `Gemfile` y corre `bundle`. Corre tus pruebas porque ya +terminaste! diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 000000000..40e1a6270 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,375 @@ +# Migrating from Paperclip to ActiveStorage + +Paperclip and ActiveStorage solve similar problems with similar solutions, so +transitioning from one to the other is straightforward data re-writing. + +The process of going from Paperclip to ActiveStorage is as follows: + +1. Apply the ActiveStorage database migrations. +2. Configure storage. +3. Copy the database data over. +4. Copy the files over. +5. Update your tests. +6. Update your views. +7. Update your controllers. +8. Update your models. + +## Apply the ActiveStorage database migrations + +Follow [the instructions for installing ActiveStorage]. You'll very likely want +to add the `mini_magick` gem to your Gemfile. + +```sh +rails active_storage:install +``` + +[the instructions for installing ActiveStorage]: https://github.com/rails/rails/blob/master/activestorage/README.md#installation + +## Configure storage + +Again, follow [the instructions for configuring ActiveStorage]. + +[the instructions for configuring ActiveStorage]: http://edgeguides.rubyonrails.org/active_storage_overview.html#setup + +## Copy the database data over + +The `active_storage_blobs` and `active_storage_attachments` tables are where +ActiveStorage expects to find file metadata. Paperclip stores the file metadata +directly on the associated object's table. + +You'll need to write a migration for this conversion. Because the models for +your domain are involved, it's tricky to supply a simple script. But we'll try! + +Here's how it would go for a `User` with an `avatar`, that is this in +Paperclip: + +```ruby +class User < ApplicationRecord + has_attached_file :avatar +end +``` + +Your Paperclip migrations will produce a table like so: + +```ruby +create_table "users", force: :cascade do |t| + t.string "avatar_file_name" + t.string "avatar_content_type" + t.integer "avatar_file_size" + t.datetime "avatar_updated_at" +end +``` + +And you'll be converting into these tables: + +```ruby +create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.integer "record_id", null: false + t.integer "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true +end +``` + +```ruby +create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true +end +``` + +So, assuming you want to leave the files in the exact same place, _this is +your migration_. Otherwise, see the next section first and modify the migration +to taste. + +```ruby +Dir[Rails.root.join("app/models/**/*.rb")].sort.each { |file| require file } + +class ConvertToActiveStorage < ActiveRecord::Migration[5.2] + require 'open-uri' + + def up + # postgres + get_blob_id = 'LASTVAL()' + # mariadb + # get_blob_id = 'LAST_INSERT_ID()' + # sqlite + # get_blob_id = 'LAST_INSERT_ROWID()' + + active_storage_blob_statement = ActiveRecord::Base.connection.raw_connection.prepare(<<-SQL) + INSERT INTO active_storage_blobs ( + `key`, filename, content_type, metadata, byte_size, checksum, created_at + ) VALUES (?, ?, ?, '{}', ?, ?, ?) + SQL + + active_storage_attachment_statement = ActiveRecord::Base.connection.raw_connection.prepare(<<-SQL) + INSERT INTO active_storage_attachments ( + name, record_type, record_id, blob_id, created_at + ) VALUES (?, ?, ?, #{get_blob_id}, ?) + SQL + + models = ActiveRecord::Base.descendants.reject(&:abstract_class?) + + transaction do + models.each do |model| + attachments = model.column_names.map do |c| + if c =~ /(.+)_file_name$/ + $1 + end + end.compact + + model.find_each.each do |instance| + attachments.each do |attachment| + active_storage_blob_statement.execute( + key(instance, attachment), + instance.send("#{attachment}_file_name"), + instance.send("#{attachment}_content_type"), + instance.send("#{attachment}_file_size"), + checksum(instance.send(attachment)), + instance.updated_at.iso8601 + ) + + active_storage_attachment_statement. + execute(attachment, model.name, instance.id, instance.updated_at.iso8601) + end + end + end + end + + active_storage_attachment_statement.close + active_storage_blob_statement.close + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + def key(instance, attachment) + SecureRandom.uuid + # Alternatively: + # instance.send("#{attachment}_file_name") + end + + def checksum(attachment) + # local files stored on disk: + url = attachment.path + Digest::MD5.base64digest(File.read(url)) + + # remote files stored on another person's computer: + # url = attachment.url + # Digest::MD5.base64digest(Net::HTTP.get(URI(url))) + end +end +``` + +## Copy the files over + +The above migration leaves the files as they are. However, the default +Paperclip and ActiveStorage storage services use different locations. + +By default, Paperclip looks like this: + +``` +public/system/users/avatars/000/000/004/original/the-mystery-of-life.png +``` + +And ActiveStorage looks like this: + +``` +storage/xM/RX/xMRXuT6nqpoiConJFQJFt6c9 +``` + +That `xMRXuT6nqpoiConJFQJFt6c9` is the `active_storage_blobs.key` value. In the +migration above we simply used the filename but you may wish to use a UUID +instead. + + +### Moving local storage files + +```ruby +#!bin/rails runner + +class ActiveStorageBlob < ActiveRecord::Base +end + +class ActiveStorageAttachment < ActiveRecord::Base + belongs_to :blob, class_name: 'ActiveStorageBlob' + belongs_to :record, polymorphic: true +end + +ActiveStorageAttachment.find_each do |attachment| + name = attachment.name + + source = attachment.record.send(name).path + dest_dir = File.join( + "storage", + attachment.blob.key.first(2), + attachment.blob.key.first(4).last(2)) + dest = File.join(dest_dir, attachment.blob.key) + + FileUtils.mkdir_p(dest_dir) + puts "Moving #{source} to #{dest}" + FileUtils.cp(source, dest) +end +``` + +### Moving files on a remote host (S3, Azure Storage, GCS, etc.) + +One of the most straightforward ways to move assets stored on a remote host is +to use a rake task that regenerates the file names and places them in the +proper file structure/hierarchy. + +Assuming you have a model configured similarly to the example below: + +```ruby +class Organization < ApplicationRecord + # New ActiveStorage declaration + has_one_attached :logo + + # Old Paperclip config + # must be removed BEFORE to running the rake task so that + # all of the new ActiveStorage goodness can be used when + # calling organization.logo + has_attached_file :logo, + path: "/organizations/:id/:basename_:style.:extension", + default_url: "https://s3.amazonaws.com/xxxxx/organizations/missing_:style.jpg", + default_style: :normal, + styles: { thumb: "64x64#", normal: "400x400>" }, + convert_options: { thumb: "-quality 100 -strip", normal: "-quality 75 -strip" } +end +``` + +The following rake task would migrate all of your assets: + +```ruby +namespace :organizations do + task migrate_to_active_storage: :environment do + Organization.where.not(logo_file_name: nil).find_each do |organization| + # This step helps us catch any attachments we might have uploaded that + # don't have an explicit file extension in the filename + image = organization.logo_file_name + ext = File.extname(image) + image_original = URI.unescape(image.gsub(ext, "_original#{ext}")) + + # this url pattern can be changed to reflect whatever service you use + logo_url = "https://s3.amazonaws.com/xxxxx/organizations/#{organization.id}/#{image_original}" + organization.logo.attach(io: open(logo_url), + filename: organization.logo_file_name, + content_type: organization.logo_content_type) + end + end +end +``` + +An added advantage of this method is that you're creating a copy of all assets, +which is handy in the event you need to rollback your deploy. + +This also means that you can run the rake task from your development machine +and completely migrate the assets before your deploy, minimizing the chances +that you'll have a timed-out deployment. + +The main drawback of this method is the same as its benefit - you are +essentially duplicating all of your assets. These days storage and bandwidth +are relatively cheap, but in some instances where you have a huge volume of +files, or very large file sizes, this might get a little less feasible. + +In my experience I was able to move tens of thousands of images in a matter of +a couple of hours, just by running the migration overnight on my MacBook Pro. + +Once you've confirmed that the migration and deploy have gone successfully you +can safely delete the old assets from your remote host. + +## Update your tests + +Instead of the `have_attached_file` matcher, you'll need to write your own. +Here's one that is similar in spirit to the Paperclip-supplied matcher: + +```ruby +RSpec::Matchers.define :have_attached_file do |name| + matches do |record| + file = record.send(name) + file.respond_to?(:variant) && file.respond_to?(:attach) + end +end +``` + +## Update your views + +In Paperclip it looks like this: + +```ruby +image_tag @user.avatar.url(:medium) +``` + +In ActiveStorage it looks like this: + +```ruby +image_tag @user.avatar.variant(resize: "250x250") +``` + +## Update your controllers + +This should _require_ no update. However, if you glance back at the database +schema above, you may notice a join. + +For example, if your controller has + +```ruby +def index + @users = User.all.order(:name) +end +``` + +And your view has + +``` + +``` + +Then you'll end up with an n+1 as you load each attachment in the loop. + +So while the controller and model will work without change, you will want to +double-check your loops and add `includes` as needed. ActiveStorage adds an +`avatar_attachment` and `avatar_blob` relationship to has-one relations, and +`avatar_attachments` and `avatar_blobs` to has-many: + +```ruby +def index + @users = User.all.order(:name).includes(:avatar_attachment) +end +``` + +## Update your models + +Follow [the guide on attaching files to records]. For example, a `User` with an +`avatar` is represented as: + +```ruby +class User < ApplicationRecord + has_one_attached :avatar +end +``` + +Any resizing is done in the view as a variant. + +[the guide on attaching files to records]: http://edgeguides.rubyonrails.org/active_storage_overview.html#attaching-files-to-records + +## Remove Paperclip + +Remove the Gem from your `Gemfile` and run `bundle`. Run your tests because +you're done! diff --git a/NEWS b/NEWS index 92be4a4ed..59fa5d4b8 100644 --- a/NEWS +++ b/NEWS @@ -1,50 +1,504 @@ -New in 2.7.0: +master: -* Bug fix: Checking the existence of a file on S3 handles all AWS errors. -* Bug fix: Clear the fingerprint when removing an attachment. -* Bug fix: Attachment size validation message reads more nicely now. +* Improvement: Better handling of the content-disposition header. Now supports file name that is either + enclosed or not in double quotes and is case insensitive as per RC6266 grammar +* Improvement: Files without an extension will now be checked for spoofing attempts + +6.0.0 (2018-03-09): + +* Improvement: Depend only on `aws-sdk-s3` instead of `aws-sdk` (https://github.com/thoughtbot/paperclip/pull/2481) + +5.3.0 (2018-03-09): + +* Improvement: Use `FactoryBot` instead of `FactoryGirl` (https://github.com/thoughtbot/paperclip/pull/2501) +* Improvement: README updates (https://github.com/thoughtbot/paperclip/pull/2411, https://github.com/thoughtbot/paperclip/pull/2433, https://github.com/thoughtbot/paperclip/pull/2374, https://github.com/thoughtbot/paperclip/pull/2417, https://github.com/thoughtbot/paperclip/pull/2536) +* Improvement: Remove Ruby 2.4 deprecation warning (https://github.com/thoughtbot/paperclip/pull/2401) +* Improvement: Rails 5 migration compatibility (https://github.com/thoughtbot/paperclip/pull/2470) +* Improvement: Documentation around post processing (https://github.com/thoughtbot/paperclip/pull/2381) +* Improvement: S3 hostname example documentation (https://github.com/thoughtbot/paperclip/pull/2379) +* Bugfix: Allow paperclip to load in IRB (https://github.com/thoughtbot/paperclip/pull/2369) +* Bugfix: MIME type detection (https://github.com/thoughtbot/paperclip/issues/2527) +* Bugfix: Bad tempfile state after symlink failure (https://github.com/thoughtbot/paperclip/pull/2540) +* Bugfix: Rewind file after Fog bucket creation (https://github.com/thoughtbot/paperclip/pull/2572) +* Improvement: Use `Terrapin` instead of `Cocaine` (https://github.com/thoughtbot/paperclip/pull/2553) + +5.2.1 (2018-01-25): + +* Bugfix: Fix copying files on Windows. (#2532) + +5.2.0 (2018-01-23): + +* Security: Remove the automatic loading of URI adapters. Some of these + adapters can be specially crafted to expose your network topology. (#2435) +* Bugfix: The rake task no longer rescues `Exception`. (#2476) +* Bugfix: Handle malformed `Content-Disposition` headers (#2283) +* Bugfix: The `:only_process` option works when passed a lambda again. (#2289) +* Improvement: Added `:use_accelerate_endpoint` option when using S3 to enable + [Amazon S3 Transfer Acceleration](http://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html) + (#2291) +* Improvement: Make the fingerprint digest configurable per attachment. The + default remains MD5. Making this configurable means it can change in a future + version because it is not considered secure anymore against intentional file + corruption. For more info, see https://en.wikipedia.org/wiki/MD5#Security + + You can change the digest used for an attachment by adding the + `:adapter_options` parameter to the `has_attached_file` options like this: + `has_attached_file :avatar, adapter_options: { hash_digest: Digest::SHA256 }` + + Use the rake task to regenerate fingerprints with the new digest for a given + class. Note that this does **not** check the file integrity using the old + fingerprint. Run the following command to regenerate fingerprints for all + User attachments: + `CLASS=User rake paperclip:refresh:fingerprints` + You can optionally limit the attachment that will be processed, e.g: + `CLASS=User ATTACHMENT=avatar rake paperclip:refresh:fingerprints` (#2229) +* Improvement: The new `frame_index` option on the thumbnail processor allows + you to select a specific frame from an animated upload to use as a thumbnail. + Initial support is for mkv, avi, MP4, mov, MPEG, and GIF. (#2155) +* Improvement: Instead of copying files, use hard links. This is an + optimization. (#2120) +* Improvement: S3 storage option `:s3_prefixes_in_alias`. (#2287) +* Improvement: Fog option `:fog_public` can be a lambda. (#2302) +* Improvement: One fewer warning on JRuby. (#2352) +* Ruby 2.4.0 compatibility (doesn't use Fixnum anymore) + +5.1.0 (2016-08-19): + +* Add default `content_type_detector` to `UploadedFileAdapter` (#2270) +* Default S3 protocol to empty string (#2038) +* Don't write original file if it wasn't reprocessed (#1993) +* Disallow trailing newlines in regular expressions (#2266) +* Support for readbyte in Paperclip attachments (#2034) +* (port from 4.3) Uri io adapter uses the content-disposition filename (#2250) +* General refactors and documentation improvements + +5.0.0 (2016-07-01): + +* Improvement: Add `read_timeout` configuration for URI Adapter download_content method. +* README adjustments for Ruby beginners (add links, elucidate model in Quick Start) +* Bugfix: Now it's possible to save images from URLs with special characters [#1932] +* Bugfix: Return false when file to copy is not present in cloud storage [#2173] +* Automatically close file while checking mime type [#2016] +* Add `read_timeout` option to `UriAdapter#download_content` method [#2232] +* Fix a nil error in content type validation matcher [#1910] +* Documentation improvements + +5.0.0.beta2 (2016-04-01): + +* Bugfix: Dynamic fog directory option is now respected +* Bugfix: Fixes cocaine duplicated paths [#2169] +* Removal of dead code (older versions of Rails and AWS SDK) +* README adjustments + +5.0.0.beta1 (2016-03-13): + +* Bug Fix: megabytes of mime-types info in logs when a spoofed media type is detected. +* Drop support to end-of-life'd ruby 2.0. +* Drop support for end-of-life'd Rails 3.2 and 4.1 +* Drop support for AWS v1 +* Remove tests for JRuby and Rubinius from Travis CI (they were failing) +* Improvement: Add `fog_options` configuration to send options to fog when + storing files. +* Extracted repository for locales only: https://github.com/thoughtbot/paperclip-i18n +* Bugfix: Original file could be unlinked during `post_process_style`, producing failures +* Bugfix for image magick scaling images up +* Memory consumption improvements +* `url` on a unpersisted record returns `default_url` rather than `nil` +* Improvement: aws-sdk v2 support + https://github.com/thoughtbot/paperclip/pull/1903 + + If your Gemfile contains aws-sdk (>= 2.0.0) and aws-sdk-v1, paperclip will use + aws-sdk v2. With aws-sdk v2, S3 storage requires you to set the s3_region. + s3_region may be nested in s3_credentials, and (if not nested in + s3_credentials) it may be a Proc. + +4.3 + +See patch versions in v4.3 NEWS: +https://github.com/thoughtbot/paperclip/blob/v4.3/NEWS + +4.3.0 (2015-06-18): + +* Improvement: Update aws-sdk and cucumber gem versions. +* Improvement: Add `length` alias for `size` method in AbstractAdapter. +* Improvement: Removed some cruft +* Improvement: deep_merge! Attachment definitions +* Improvement: Switch to mimemagic gem for content-type detection +* Improvement: Allows multiple content types for spoof detector +* Bug Fix: Don't assume we have Rails.env if we have Rails +* Performance: Decrease Memory footprint +* Ruby Versioning: Drop support for 1.9.3 (EOL'ed) +* Rails Versioning: Drop support for 4.0.0 (EOL'ed) + +4.2.4 (2015-06-05): + +* Rollback backwards incompatible change, allowing paperclip to run on + Ruby >= 1.9.2. + +4.2.3: + +* Fix dependency specifications (didn't work with Rails 4.1) +* Fix paperclip tests in CI + +4.2.2: + +* Security fix: Fix a potential security issue with spoofing + +4.2.1: + +* Improvement: Added `validate_media_type` options to allow/bypass spoof check +* Improvement: Added incremental backoff when AWS gives us a SlowDown error. +* Improvement: Stream downloads when usign aws-sdk. +* Improvement: Documentation fixes, includes Windows instructions. +* Improvement: Added pt-BR, zh-HK, zh-CN, zh-TW, and ja-JP locales. +* Improvement: Better escaping for characters in URLs +* Improvement: Honor `fog_credentials[:scheme]` +* Improvement: Also look for custom processors in lib/paperclip +* Improvement: id partitioning for string IDs works like integer id +* Improvement: Can pass options to DB adapters in migrations +* Improvement: Update expiring_url creation for later versions of fog +* Improvement: `path` can be a Proc in S3 attachments +* Test Fix: Improves speed and reliability of the specs +* Bug Fix: #original_filename= does not error when passed `nil` + +4.2.0: + +* Improvement: Converted test suite from test/unit to RSpec +* Improvement: Refactored Paperclip::Attachment#assign +* Improvement: Added Spanish and German locales +* Improvement: Required Validators accept validator subclasses +* Improvement: EXIF orientation checking can be turned off for performance +* Improvement: Documentation updates +* Improvement: Better #human_size method for AttachmentSizeValidators +* Bug Fix: Allow MIME-types with dots in them +* Improvement: Travis CI updates +* Improvement: Validators can take multiple messages +* Improvement: Per-style options for S3 storage +* Improvement: Allow `nil` geometry strings +* Improvement: Use `eager_load!` + +4.1.1: + +* Improvement: Add default translations for spoof validation +* Bug Fix: Don't check for spoofs if the file hasn't changed +* Bug Fix: Callback chain terminator is different in Rails 4.1, remove warnings +* Improvement: Fixed various Ruby warnings +* Bug Fix: Give bundler a hint, so it doesn't run forever on a fresh bundle +* Improvement: Documentation fixes +* Improvement: Allow travis-ci to finish-fast + + +4.1.0: + +* Improvement: Add :content_type_mappings to correct for missing spoof types +* Improvement: Credit Egor Homakov with discovering the content_type spoof bug +* Improvement: Memoize calls to identify in the thumbnail processor +* Improvement: Make MIME type optional for Data URIs. +* Improvement: Add default format for styles + +4.0.0: + +* Security: Attachments are checked to make sure they're not pulling a fast one. +* Security: It is now *enforced* that every attachment has a file/mime validation. +* Bug Fix: Removed a call to IOAdapter#close that was causing issues. +* Improvement: Added bullets to the 3.5.3 list of changes. Very important. +* Improvement: Updated the copyright to 2014 + +3.5.3: + +* Improvement: After three long, hard years... we know how to upgrade +* Bug Fix: #expiring_url returns 'missing' urls if nothing is attached +* Improvement: Lots of documentation fixes +* Improvement: Lots of fixes for Ruby warnings +* Improvement: Test the most appropriate Ruby/Rails comobinations on Travis +* Improvement: Delegate more IO methods through IOAdapters +* Improvement: Remove Rails 4 deprecations +* Improvement: Both S3's and Fog's #expiring_url can take a Time or Int +* Bug Fix: Both S3's and Fog's expiring_url respect style when missing the file +* Bug Fix: Timefiles will have a reasonable-length name. They're all MD5 hashes now +* Bug Fix: Don't delete files off S3 when reprocessing due to AWS inconsistencies +* Bug Fix: "swallow_stream" isn't thread dafe. Use :swallow_stderr +* Improvement: Regexps use \A and \Z instead of ^ and $ +* Improvement: :s3_credentials can take a lambda as an argument +* Improvement: Search up the class heirarchy for attachments +* Improvement: deep_merge options instead of regular merge +* Bug Fix: Prevent file deletion on transaction rollback +* Test Improvement: Ensure more files are properly closed during tests +* Test Bug Fix: Return the gemfile's syntax to normal + +3.5.2: + +* Security: Force cocaine to at least 0.5.3 to include a security fix +* Improvement: Fixed some README exmaples +* Feature: Added HTTP URL Proxy Adapter, can assign string URLs as attachments +* Improvement: Put validation errors on the base attribute and the sub-attribute + +3.5.1: + +* Bug Fix: Returned the class-level `attachment_definitions` method for compatability. +* Improvement: Ensured compatability with Rails 4 +* Improvement: Added Rails 4 to the Appraisals +* Bug Fix: #1296, where validations were generating errors +* Improvement: Specify MIT license in the gemspec + +3.5.0: + +* Feature: Handle Base64-encoded data URIs as uploads +* Feature: Add a FilenameCleaner class to allow custom filename sanitation +* Improvement: Satisfied Mocha deprecation warnings +* Bug Fix: Allow empty string to be submitted and ignored, as some forms do this +* Improvement: Make #expiring_url behavior consistent with #url +* Bug Fix: "Validate" attachments without invoking AR's validations +* Improvement: Various refactorings for a cleaner codebase +* Improvement: Be agnostic, use ActiveModel when appropriate +* Improvement: Add validation errors to the base attachment attribute +* Improvement: Handle errors in rake tasks +* Improvement: Largely refactor has_attached_file into a new class +* Improvement: Added Ruby 2.0.0 as a supported platform and removed 1.8.7 +* Improvement: Fixed some incompatabilities in the test suite + +3.4.2: + +* Improvement: Use https for Gemfile urls +* Improvement: Updated and more correct documentation +* Improvement: Use the -optimize flag on animated GIFs +* Improvement: Remove the Gemfile.lock +* Improvement: Add #expiring_url as an alias for #url until the storage defines it +* Improvement: Remove path clash checking, as it's unnecessary +* Bug Fix: Do not rely on checking version numbers for aws-sdk + +3.4.1: + +* Improvement: Various documentation fixes and improvements +* Bug Fix: Clearing an attachment with `preserve_files` on should still clear the attachment +* Bug Fix: Instances are #changed? when a new file is assigned +* Bug Fix: Correctly deal with S3 styles when using a lambda +* Improvement: Accept and pass :credential_provider option to AWS-SDK +* Bug Fix: Sanitize original_filename more correctly in IO Adapters +* Improvement: s3_host_name can be a lambda +* Improvement: Cache some interpolations for speed +* Improvement: Update to latest cocaine +* Improvement: Update copyrights, various typos + +3.4.0: + +* Bug Fix: Allow UploadedFileAdapter to force the use of `file` +* Bug Fix: Close the file handle when dealing with URIs +* Bug Fix: Ensure files are closed for writing when we're done. +* Bug Fix: Fixed 'type' being nil on Windows 7 error. +* Bug Fix: Fixed nil access when no s3 headers are defined +* Bug Fix: Fixes auto_orientation +* Bug Fix: Prevent a missing method error when switching from aws_sdk to fog +* Bug Fix: Properly fail to process invalid attachments +* Bug Fix: Server-side encryption is specified correctly +* Bug Fix: fog_public returned to true by default +* Bug Fix: Check attachment paths for duplicates, not URLs +* Feature: Add Attachment#blank? +* Feature: Add support for blacklisting certain content_types +* Feature: Add support for style-specific s3 headers and meta data +* Feature: Allow only_process to be a lambda +* Feature: Allow setting of escape url as a default option +* Feature: Create :override_file_permissions option for filesystem attachments +* Improvement: Add Attachment#as_json +* Improvement: Evaluate lambdas for fog_file properties +* Improvement: Extract geometry parsing into factories +* Improvement: Fixed various typos +* Improvement: Refactored some tests +* Improvement: Reuse S3 connections + +New In 3.3.1: + +* Bug Fix: Moved Filesystem's copy_to_local_file to the right place. + +3.3.0: + +* Improvement: Upgrade cocaine to 0.4 + +3.2.0: + +* Bug Fix: Use the new correct Amazon S3 encryption header. +* Bug Fix: The rake task respects the updated_at column. +* Bug Fix: Strip newline from content type. +* Feature: Fog file visibility can be specified per style. +* Feature: Automatically rotate images. +* Feature: Reduce class-oriented programming of the attachment definitions. + +3.1.4: + +* Bug Fix: Allow user to be able to set path without `:style` attribute and not raising an error. + This is a regression introduced in 3.1.3, and that feature will be postponed to another minor + release instead. +* Feature: Allow for URI Adapter as an optional paperclip io adapter. + +3.1.3: + +* Bug Fix: Copy empty attachment between instances is now working. +* Bug Fix: Correctly rescue Fog error. +* Bug Fix: Using default path and url options in Fog storage now work as expected. +* Bug Fix: `Attachment#s3_protocol` now returns a protocol without colon suffix. +* Feature: Paperclip will now raise an error if multiple styles are defined but no `:style` + interpolation exists in `:path`. +* Feature: Add support for `#{attachment}_created_at` field +* Bug Fix: Paperclip now gracefully handles msising file command. +* Bug Fix: `StringIOAdapter` now accepts content type. + +3.1.2: + +* Bug Fix: #remove_attachment on 3.1.0 and 3.1.1 mistakenly trying to remove the column that has + the same name as data type (such as :string, :datetime, :interger.) You're advised to update to + Paperclip 3.1.2 as soon as possible. + +3.1.1: + +* Bug Fix: Paperclip will only load Paperclip::Schema only when Active Record is available. + +3.1.0: + +* Feature: Paperclip now support new migration syntax (sexy migration) that reads better: + + class AddAttachmentToUsers < ActiveRecord::Migration + def self.up + create_table :users do |t| + t.attachment :avatar + end + end + end + + Also, schema-definition level syntax has been added: + + add_attachment :users, :avatar + remove_attachment :users, :avatar + +* Feature: Migration now support Rails 3.2+ `change` method. +* API CHANGE: Old `t.has_attached_file` and `drop_attached_file` are now deprecated. You're advised + to update your migration file before the next MAJOR version. +* Bug Fix: Tempfile now rewinded before generating fingerprint +* API CHANGE: Tempfiles are now unlinked after `after_flush_writes` + + If you need to interact with the generated tempfiles, please define an `after_flush_writes` method + in your model. You'll be able to access files via `@queue_for_write` instance variable. + +* Bug Fix: `:s3_protocol` can now be defined as either String or Symbol +* Bug Fix: Tempfiles are now rewinded before get passed into `after_flush_writes` +* Feature: Added expiring_url method to Fog Storage +* API CHANGE: Paperclip now tested against AWS::SDK 1.5.2 onward +* Bug Fix: Improved the output of the content_type validator so the actual failure is displayed +* Feature: Animated formats now identified using ImageMagick. +* Feature: AttachmentAdapter now support fetching attachment with specific style. +* Feature: Paperclip default options can now be configured in Rails.configuration. +* Feature: add Geometry#resize_to to calculate dimensions of new source. +* Bug Fix: Fixed a bug whereby a file type with multiple mime types but no official type would cause + the best_content_type to throw an error on trying nil.content_type. +* Bug Fix: Fix problem when the gem cannot be installed on the system that has Asepsis installed. + +3.0.4: + +* Feature: Adds support for S3 scheme-less URL generation. + +3.0.3: + +* Bug Fix: ThumbnailProcessor now correctly detects and preserve animated GIF. +* Bug Fix: File extension is now preserved in generated Tempfile from adapter. +* Bug Fix: Uploading file with unicode file name now won't raise an error when + logging in the AWS is turned on. +* Bug Fix: Task "paperclip:refresh:missing_styles" now work correctly. +* Bug Fix: Handle the case when :restricted_characters is nil. +* Bug Fix: Don't delete all the existing styles if we reprocess. +* Bug Fix: Content type is now ensured to not having a new line character. +* API CHANGE: Non-Rails usage should include Paperclip::Glue directly. + + `Paperclip::Railtie` was intended to be used with Ruby on Rails only. If you're + using Paperclip without Rails, you should include `Paperclip::Glue` into + `ActiveRecord::Base` instead of requiring `paperclip/railtie`: + + ActiveRecord::Base.send :include, Paperclip::Glue + +* Bug Fix: AttachmentContentTypeValidator now allow you to specify :allow_blank/:allow_nil +* Bug Fix: Make sure content type always a String. +* Bug Fix: Fix attachment.reprocess! when using storage providers fog and s3. +* Bug Fix: Fix a problem with incorrect content_type detected with 'file' command for an empty file on Mac. + +3.0.2: + +* API CHANGE: Generated migration class name is now plural (AddAttachmentToUsers instead of AddAttachmentToUser) +* API CHANGE: Remove Rails plugin initialization code. +* API CHANGE: Explicitly require Ruby 1.9.2 in the Gemfile. +* Bug Fix: Fixes AWS::S3::Errors::RequestTimeout on Model#save. +* Bug Fix: Fix a problem when there's no logger specified. +* Bug Fix: Fix a problem when attaching Rack::Test::UploadedFile instance. + +3.0.1: + +* Feature: Introduce Paperlip IO adapter. +* Bug Fix: Regression in AttachmentContentTypeValidator has been fixed. +* API CHANGE: #to_file has been removed. Use the #copy_to_local_file method instead. + +3.0.0: + +* API CHANGE: Paperclip now requires at least Ruby on Rails version 3.0.0 +* API CHANGE: The default :url and :path have changed. The new scheme avoids + filesystem conflicts and scales to handle larger numbers of uploads. + + The easiest way to upgrade is to add an explicit :url and :path to your + has_attached_file calls: + + has_attached_file :avatar, + :path => ":rails_root/public/system/:attachment/:id/:style/:filename", + :url => "/system/:attachment/:id/:style/:filename" + +* Feature: Adding Rails 3 style validators, and adding `validates_attachment` method as a shorthand. +* Bug Fix: Paperclip's rake tasks now loading records in batch. +* Bug Fix: Attachment style name with leading number now not raising an error. +* Bug Fix: File given to S3 and Fog storage will now be rewinded after flush_write. +* Feature: You can now pass addional parameter to S3 expiring URL, such as :content_type. + +2.7.0: + +* Bug Fix: Checking the existence of a file on S3 handles all AWS errors. +* Bug Fix: Clear the fingerprint when removing an attachment. +* Bug Fix: Attachment size validation message reads more nicely now. * Feature: Style names can be either symbols or strings. * Compatibility: Support for ActiveSupport < 2.3.12. * Compatibility: Support for Rails 3.2. -New in 2.6.0: +2.6.0: -* Bug fix: Files are re-wound after reading. +* Bug Fix: Files are re-wound after reading. * Feature: Remove Rails dependency from specs that need Paperclip. * Feature: Validation matchers support conditionals. -New in 2.5.2: +2.5.2: -* Bug fix: Can be installed on Windows. +* Bug Fix: Can be installed on Windows. * Feature: The Fog bucket name, authentication, and host can be determined at runtime via Proc. * Feature: Special characters are replaced with underscores in #url and #path. -New in 2.5.1: +2.5.1: * Feature: After we've computed the content type, pass it to Fog. * Feature: S3 encryption with the new :s3_server_side_encryption option. * Feature: Works without ActiveRecord, allowing for e.g. mongo backends. -New in 2.5.0: +2.5.0: * Performance: Only connect to S3 when absolutely needed. -* Bug fix: STI with cached classes respect new options. -* Bug fix: conditional validations broke, and now work again. +* Bug Fix: STI with cached classes respect new options. +* Bug Fix: conditional validations broke, and now work again. * Feature: URL generation is now parameterized and can be changed with plugins or custom code. * Feature: :convert_options and :source_file_options to control the ImageMagick processing. * Performance: String geometry specifications now parse more quickly. -* Bug fix: Handle files with question marks in the filename. -* Bug fix: Don't raise an error when generating an expiring URL on an unassigned attachment. -* Bug fix: The rake task runs over all instances of an ActiveRecord model, ignoring default scopes. +* Bug Fix: Handle files with question marks in the filename. +* Bug Fix: Don't raise an error when generating an expiring URL on an unassigned attachment. +* Bug Fix: The rake task runs over all instances of an ActiveRecord model, ignoring default scopes. * Feature: DB migration has_attached_file and drop_attached_file methods. -* Bug fix: Switch from AWS::S3 to AWS::SDK for the S3 backend. -* Bug fix: URL generator uses '?' in the URL unless it already appears and there is no prior '='. -* Bug fix: Always convert the content type to a string before stripping blanks. +* Bug Fix: Switch from AWS::S3 to AWS::SDK for the S3 backend. +* Bug Fix: URL generator uses '?' in the URL unless it already appears and there is no prior '='. +* Bug Fix: Always convert the content type to a string before stripping blanks. * Feature: The :keep_old_files option preserves the files in storage even when the attachment is cleared or changed. * Performance: Optimize Fog's public_url access by avoiding it when possible. -* Bug fix: Avoid a runtime error when generating the ID partition for an unsaved attachment. +* Bug Fix: Avoid a runtime error when generating the ID partition for an unsaved attachment. * Performance: Do not calculate the fingerprint if it is never persisted. -* Bug fix: Process the :original style before all others, in case of a dependency. +* Bug Fix: Process the :original style before all others, in case of a dependency. * Feature: S3 headers can be set at runtime by passing a proc object as the value. -* Bug fix: Generating missing attachment styles for a model which has had its attachment changed should not raise. -* Bug fix: Do not collide with the built-in Ruby hashing method. +* Bug Fix: Generating missing attachment styles for a model which has had its attachment changed should not raise. +* Bug Fix: Do not collide with the built-in Ruby hashing method. diff --git a/README.md b/README.md index f5d252671..ab37d100e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,90 @@ Paperclip ========= -[![Build Status](https://secure.travis-ci.org/thoughtbot/paperclip.png?branch=master)](http://travis-ci.org/thoughtbot/paperclip) [![Dependency Status](https://gemnasium.com/thoughtbot/paperclip.png?travis)](https://gemnasium.com/thoughtbot/paperclip) - -Paperclip is intended as an easy file attachment library for Active Record. The +# Deprecated + +**[Paperclip is deprecated]**. + +For new projects, we recommend Rails' own [ActiveStorage]. + +For existing projects, please consult and contribute to [the migration guide] ([en español]). + + +We will leave the Issues open as a discussion forum _only_. We do _not_ +guarantee a response from us in the Issues. + +We are no longer accepting pull requests _except_ pull requests against the +migration guide. All other pull requests will be closed without merging. + +[Paperclip is deprecated]: https://robots.thoughtbot.com/closing-the-trombone +[ActiveStorage]: http://guides.rubyonrails.org/active_storage_overview.html +[the migration guide]: https://github.com/thoughtbot/paperclip/blob/master/MIGRATING.md +[en español]: https://github.com/thoughtbot/paperclip/blob/master/MIGRATING-ES.md + +# Existing documentation + +## Documentation valid for `master` branch + +Please check the documentation for the paperclip version you are using: +https://github.com/thoughtbot/paperclip/releases + +--- + +[![Build Status](https://secure.travis-ci.org/thoughtbot/paperclip.svg?branch=master)](http://travis-ci.org/thoughtbot/paperclip) +[![Dependency Status](https://gemnasium.com/thoughtbot/paperclip.svg?travis)](https://gemnasium.com/thoughtbot/paperclip) +[![Code Climate](https://codeclimate.com/github/thoughtbot/paperclip.svg)](https://codeclimate.com/github/thoughtbot/paperclip) +[![Inline docs](http://inch-ci.org/github/thoughtbot/paperclip.svg)](http://inch-ci.org/github/thoughtbot/paperclip) +[![Security](https://hakiri.io/github/thoughtbot/paperclip/master.svg)](https://hakiri.io/github/thoughtbot/paperclip/master) + + + + +- [Requirements](#requirements) + - [Ruby and Rails](#ruby-and-rails) + - [Image Processor](#image-processor) + - [`file`](#file) +- [Installation](#installation) +- [Quick Start](#quick-start) + - [Models](#models) + - [Migrations](#migrations) + - [Edit and New Views](#edit-and-new-views) + - [Edit and New Views with Simple Form](#edit-and-new-views-with-simple-form) + - [Controller](#controller) + - [View Helpers](#view-helpers) + - [Checking a File Exists](#checking-a-file-exists) + - [Deleting an Attachment](#deleting-an-attachment) +- [Usage](#usage) +- [Validations](#validations) +- [Internationalization (I18n)](#internationalization-i18n) +- [Security Validations](#security-validations) +- [Defaults](#defaults) +- [Migrations](#migrations-1) + - [Add Attachment Column To A Table](#add-attachment-column-to-a-table) + - [Schema Definition](#schema-definition) + - [Vintage Syntax](#vintage-syntax) +- [Storage](#storage) + - [Understanding Storage](#understanding-storage) +- [IO Adapters](#io-adapters) +- [Post Processing](#post-processing) +- [Custom Attachment Processors](#custom-attachment-processors) +- [Events](#events) +- [URI Obfuscation](#uri-obfuscation) +- [Checksum / Fingerprint](#checksum--fingerprint) +- [File Preservation for Soft-Delete](#file-preservation-for-soft-delete) +- [Dynamic Configuration](#dynamic-configuration) + - [Dynamic Styles:](#dynamic-styles) + - [Dynamic Processors:](#dynamic-processors) +- [Logging](#logging) +- [Deployment](#deployment) + - [Attachment Styles](#attachment-styles) +- [Testing](#testing) +- [Contributing](#contributing) +- [License](#license) +- [About thoughtbot](#about-thoughtbot) + + + +Paperclip is intended as an easy file attachment library for ActiveRecord. The intent behind it was to keep setup as easy as possible and to treat files as much like other attributes as possible. This means they aren't saved to their final locations on disk, nor are they deleted if set to nil, until @@ -15,16 +96,24 @@ packages). Attached files are saved to the filesystem and referenced in the browser by an easily understandable specification, which has sensible and useful defaults. -See the documentation for `has_attached_file` in [`Paperclip::ClassMethods`](http://rubydoc.info/gems/paperclip/Paperclip/ClassMethods) for +See the documentation for `has_attached_file` in [`Paperclip::ClassMethods`](http://www.rubydoc.info/gems/paperclip/Paperclip/ClassMethods) for more detailed options. -The complete [RDoc](http://rdoc.info/gems/paperclip) is online. +The complete [RDoc](http://www.rubydoc.info/gems/paperclip) is online. +--- Requirements ------------ -ImageMagick must be installed and Paperclip must have access to it. To ensure +### Ruby and Rails + +Paperclip now requires Ruby version **>= 2.1** and Rails version **>= 4.2** +(only if you're going to use Paperclip with Ruby on Rails). + +### Image Processor + +[ImageMagick](http://www.imagemagick.org) must be installed and Paperclip must have access to it. To ensure that it does, on your command line, run `which convert` (one of the ImageMagick utilities). This will give you the path where that utility is installed. For example, it might return `/usr/local/bin/convert`. @@ -34,108 +123,197 @@ directory to its path. In development mode, you might add this line to `config/environments/development.rb)`: - Paperclip.options[:command_path] = "/usr/local/bin/" +```ruby +Paperclip.options[:command_path] = "/usr/local/bin/" +``` -If you're on Mac OS X, you'll want to run the following with Homebrew: +If you're on Mac OS X, you'll want to run the following with [Homebrew](http://www.brew.sh): brew install imagemagick If you are dealing with pdf uploads or running the test suite, you'll also need -GhostScript to be installed. On Mac OS X, you can also install that using Homebrew: +to install GhostScript. On Mac OS X, you can also install that using Homebrew: brew install gs +If you are on Ubuntu (or any Debian base Linux distribution), you'll want to run +the following with apt-get: + + sudo apt-get install imagemagick -y + +### `file` + +The Unix [`file` command](https://en.wikipedia.org/wiki/File_(command)) is required for content-type checking. +This utility isn't available in Windows, but comes bundled with Ruby [Devkit](https://github.com/oneclick/rubyinstaller/wiki/Development-Kit), +so Windows users must make sure that the devkit is installed and added to the system `PATH`. + +**Manual Installation** + +If you're using Windows 7+ as a development environment, you may need to install the `file.exe` application manually. The `file spoofing` system in Paperclip 4+ relies on this; if you don't have it working, you'll receive `Validation failed: Upload file has an extension that does not match its contents.` errors. + +To manually install, you should perform the following: + +> **Download & install `file` from [this URL](http://gnuwin32.sourceforge.net/packages/file.htm)** + +To test, you can use the image below: +![untitled](https://cloud.githubusercontent.com/assets/1104431/4524452/a1f8cce4-4d44-11e4-872e-17adb96f79c9.png) + +Next, you need to integrate with your environment - preferably through the `PATH` variable, or by changing your `config/environments/development.rb` file + +**PATH** + + 1. Click "Start" + 2. On "Computer", right-click and select "Properties" + 3. In Properties, select "Advanced System Settings" + 4. Click the "Environment Variables" button + 5. Locate the "PATH" var - at the end, add the path to your newly installed `file.exe` (typically `C:\Program Files (x86)\GnuWin32\bin`) + 6. Restart any CMD shells you have open & see if it works + +OR + +**Environment** + + 1. Open `config/environments/development.rb` + 2. Add the following line: `Paperclip.options[:command_path] = 'C:\Program Files (x86)\GnuWin32\bin'` + 3. Restart your Rails server + +Either of these methods will give your Rails setup access to the `file.exe` functionality, thus providing the ability to check the contents of a file (fixing the spoofing problem) + +--- Installation ------------ -Paperclip is distributed as a gem, which is how it should be used in your app. It's -technically still installable as a plugin, but that's discouraged, as Rails plays -well with gems. +Paperclip is distributed as a gem, which is how it should be used in your app. Include the gem in your Gemfile: - gem "paperclip", "~> 2.0" +```ruby +gem "paperclip", "~> 6.0.0" +``` Or, if you want to get the latest, you can get master from the main paperclip repository: - gem "paperclip", :git => "git://github.com/thoughtbot/paperclip.git" +```ruby +gem "paperclip", git: "git://github.com/thoughtbot/paperclip.git" +``` If you're trying to use features that don't seem to be in the latest released gem, but are mentioned in this README, then you probably need to specify the master branch if you want to -use them. This README is probably ahead of the latest released version, if you're reading it +use them. This README is probably ahead of the latest released version if you're reading it on GitHub. -Anyway, if you don't use Bundler (though you probably should, even in Rails 2), with config.gem - - # In config/environment.rb - ... - Rails::Initializer.run do |config| - ... - config.gem "paperclip", :version => "~> 2.4" - ... - end - For Non-Rails usage: - class ModuleName < ActiveRecord::Base - include Paperclip::Glue - ... - end +```ruby +class ModuleName < ActiveRecord::Base + include Paperclip::Glue + ... +end +``` + +--- Quick Start ----------- -In your model: +### Models - class User < ActiveRecord::Base - has_attached_file :avatar, :styles => { :medium => "300x300>", :thumb => "100x100>" } - end +```ruby +class User < ActiveRecord::Base + has_attached_file :avatar, styles: { medium: "300x300>", thumb: "100x100>" }, default_url: "/images/:style/missing.png" + validates_attachment_content_type :avatar, content_type: /\Aimage\/.*\z/ +end +``` -In your migrations: +### Migrations - class AddAvatarColumnsToUser < ActiveRecord::Migration - def self.up - change_table :users do |t| - t.has_attached_file :avatar - end - end - def self.down - drop_attached_file :users, :avatar - end - end +Assuming you have a `users` table, add an `avatar` column to the `users` table: +```ruby +class AddAvatarColumnsToUsers < ActiveRecord::Migration + def up + add_attachment :users, :avatar + end -In your edit and new views: + def down + remove_attachment :users, :avatar + end +end +``` - <%= form_for :user, @user, :url => user_path, :html => { :multipart => true } do |form| %> - <%= form.file_field :avatar %> - <% end %> +(Or you can use the Rails migration generator: `rails generate paperclip user avatar`) -In your controller: +### Edit and New Views +Make sure you have corresponding methods in your controller: +```erb +<%= form_for @user, url: users_path, html: { multipart: true } do |form| %> + <%= form.file_field :avatar %> + <%= form.submit %> +<% end %> +``` - def create - @user = User.create( params[:user] ) - end +### Edit and New Views with [Simple Form](https://github.com/plataformatec/simple_form) + +```erb +<%= simple_form_for @user, url: users_path do |form| %> + <%= form.input :avatar, as: :file %> + <%= form.submit %> +<% end %> +``` + +### Controller + +```ruby +def create + @user = User.create(user_params) +end + +private -In your show view: +# Use strong_parameters for attribute whitelisting +# Be sure to update your create() and update() controller methods. - <%= image_tag @user.avatar.url %> - <%= image_tag @user.avatar.url(:medium) %> - <%= image_tag @user.avatar.url(:thumb) %> +def user_params + params.require(:user).permit(:avatar) +end +``` -To detach a file, simply set the attribute to `nil`: +### View Helpers +Add these to the view where you want your images displayed: +```erb +<%= image_tag @user.avatar.url %> +<%= image_tag @user.avatar.url(:medium) %> +<%= image_tag @user.avatar.url(:thumb) %> +``` - @user.avatar = nil - @user.save +### Checking a File Exists + +There are two methods for checking if a file exists: + +- `file?` and `present?` checks if the `_file_name` field is populated +- `exists?` checks if the file exists (will perform a TCP connection if stored in the cloud) + +Keep this in mind if you are checking if files are present in a loop. The first +version is significantly more performant, but has different semantics. + +### Deleting an Attachment + +Set the attribute to `nil` and save. + +```ruby +@user.avatar = nil +@user.save +``` +--- Usage ----- -The basics of paperclip are quite simple: Declare that your model has an +The basics of Paperclip are quite simple: Declare that your model has an attachment with the `has_attached_file` method, and give it a name. -Paperclip will wrap up up to four attributes (all prefixed with that attachment's name, +Paperclip will wrap up to four attributes (all prefixed with that attachment's name, so you can have multiple attachments per model if you wish) and give them a friendly front end. These attributes are: @@ -144,43 +322,308 @@ friendly front end. These attributes are: * `_content_type` * `_updated_at` -By default, only `_file_name` is required for paperclip to operate. +By default, only `_file_name` is required for Paperclip to operate. You'll need to add `_content_type` in case you want to use content type validation. -More information about the options to `has_attached_file` is available in the -documentation of [`Paperclip::ClassMethods`](http://rubydoc.info/gems/paperclip/Paperclip/ClassMethods). +More information about the options passed to `has_attached_file` is available in the +documentation of [`Paperclip::ClassMethods`](http://www.rubydoc.info/gems/paperclip/Paperclip/ClassMethods). + +Validations +----------- + +For validations, Paperclip introduces several validators to validate your attachment: -For validations, attachments can be validated with these Paperclip's validation methods: +* `AttachmentContentTypeValidator` +* `AttachmentPresenceValidator` +* `AttachmentSizeValidator` + +Example Usage: + +```ruby +validates :avatar, attachment_presence: true +validates_with AttachmentPresenceValidator, attributes: :avatar +validates_with AttachmentSizeValidator, attributes: :avatar, less_than: 1.megabytes + +``` + +Validators can also be defined using the old helper style: * `validates_attachment_presence` * `validates_attachment_content_type` * `validates_attachment_size` +Example Usage: + +```ruby +validates_attachment_presence :avatar +``` + +Lastly, you can also define multiple validations on a single attachment using `validates_attachment`: + +```ruby +validates_attachment :avatar, presence: true, + content_type: "image/jpeg", + size: { in: 0..10.kilobytes } +``` + +_NOTE: Post-processing will not even **start** if the attachment is not valid +according to the validations. Your callbacks and processors will **only** be +called with valid attachments._ + +```ruby +class Message < ActiveRecord::Base + has_attached_file :asset, styles: { thumb: "100x100#" } + + before_post_process :skip_for_audio + + def skip_for_audio + ! %w(audio/ogg application/ogg).include?(asset_content_type) + end +end +``` + +If you have other validations that depend on assignment order, the recommended +course of action is to prevent the assignment of the attachment until +afterwards, then assign manually: + +```ruby +class Book < ActiveRecord::Base + has_attached_file :document, styles: { thumbnail: "60x60#" } + validates_attachment :document, content_type: "application/pdf" + validates_something_else # Other validations that conflict with Paperclip's +end + +class BooksController < ApplicationController + def create + @book = Book.new(book_params) + @book.document = params[:book][:document] + @book.save + respond_with @book + end + + private + + def book_params + params.require(:book).permit(:title, :author) + end +end +``` + +**A note on content_type validations and security** + +You should ensure that you validate files to be only those MIME types you +explicitly want to support. If you don't, you could be open to +XSS attacks +if a user uploads a file with a malicious HTML payload. + +If you're only interested in images, restrict your allowed content_types to +image-y ones: + +```ruby +validates_attachment :avatar, + content_type: ["image/jpeg", "image/gif", "image/png"] +``` + +`Paperclip::ContentTypeDetector` will attempt to match a file's extension to an +inferred content_type, regardless of the actual contents of the file. + +--- + +Internationalization (I18n) +--------------------------- + +For using or adding locale files in different languages, check the project +https://github.com/thoughtbot/paperclip-i18n. + +Security Validations +==================== + +Thanks to a report from [Egor Homakov](http://homakov.blogspot.com/) we have +taken steps to prevent people from spoofing Content-Types and getting data +you weren't expecting onto your server. + +NOTE: Starting at version 4.0.0, all attachments are *required* to include a +content_type validation, a file_name validation, or to explicitly state that +they're not going to have either. *Paperclip will raise an error* if you do not +do this. + +```ruby +class ActiveRecord::Base + has_attached_file :avatar + # Validate content type + validates_attachment_content_type :avatar, content_type: /\Aimage/ + # Validate filename + validates_attachment_file_name :avatar, matches: [/png\z/, /jpe?g\z/] + # Explicitly do not validate + do_not_validate_attachment_file_type :avatar +end +``` + +This keeps Paperclip secure-by-default, and will prevent people trying to mess +with your filesystem. + +NOTE: Also starting at version 4.0.0, Paperclip has another validation that +cannot be turned off. This validation will prevent content type spoofing. That +is, uploading a PHP document (for example) as part of the EXIF tags of a +well-formed JPEG. This check is limited to the media type (the first part of the +MIME type, so, 'text' in `text/plain`). This will prevent HTML documents from +being uploaded as JPEGs, but will not prevent GIFs from being uploaded with a +`.jpg` extension. This validation will only add validation errors to the form. It +will not cause errors to be raised. + +This can sometimes cause false validation errors in applications that use custom +file extensions. In these cases you may wish to add your custom extension to the +list of content type mappings by creating `config/initializers/paperclip.rb`: + +```ruby +# Allow ".foo" as an extension for files with the MIME type "text/plain". +Paperclip.options[:content_type_mappings] = { + foo: %w(text/plain) +} +``` + +--- + +Defaults +-------- +Global defaults for all your Paperclip attachments can be defined by changing the Paperclip::Attachment.default_options Hash. This can be useful for setting your default storage settings per example so you won't have to define them in every `has_attached_file` definition. + +If you're using Rails, you can define a Hash with default options in `config/application.rb` or in any of the `config/environments/*.rb` files on config.paperclip_defaults. These will get merged into `Paperclip::Attachment.default_options` as your Rails app boots. An example: + +```ruby +module YourApp + class Application < Rails::Application + # Other code... + + config.paperclip_defaults = { storage: :fog, fog_credentials: { provider: "Local", local_root: "#{Rails.root}/public"}, fog_directory: "", fog_host: "localhost"} + end +end +``` + +Another option is to directly modify the `Paperclip::Attachment.default_options` Hash - this method works for non-Rails applications or is an option if you prefer to place the Paperclip default settings in an initializer. + +An example Rails initializer would look something like this: + +```ruby +Paperclip::Attachment.default_options[:storage] = :fog +Paperclip::Attachment.default_options[:fog_credentials] = { provider: "Local", local_root: "#{Rails.root}/public"} +Paperclip::Attachment.default_options[:fog_directory] = "" +Paperclip::Attachment.default_options[:fog_host] = "http://localhost:3000" +``` +--- + +Migrations +---------- + +Paperclip defines several migration methods which can be used to create the necessary columns in your +model. There are two types of helper methods to aid in this, as follows: + +### Add Attachment Column To A Table + +The `attachment` helper can be used when creating a table: + +```ruby +class CreateUsersWithAttachments < ActiveRecord::Migration + def up + create_table :users do |t| + t.attachment :avatar + end + end + + # This is assuming you are only using the users table for Paperclip attachment. Drop with care! + def down + drop_table :users + end +end +``` + +You can also use the `change` method, instead of the `up`/`down` combination above, as shown below: + +```ruby +class CreateUsersWithAttachments < ActiveRecord::Migration + def change + create_table :users do |t| + t.attachment :avatar + end + end +end +``` + +### Schema Definition + +Alternatively, the `add_attachment` and `remove_attachment` methods can be used to add new Paperclip columns to an existing table: + +```ruby +class AddAttachmentColumnsToUsers < ActiveRecord::Migration + def up + add_attachment :users, :avatar + end + + def down + remove_attachment :users, :avatar + end +end +``` + +Or you can do this with the `change` method: + +```ruby +class AddAttachmentColumnsToUsers < ActiveRecord::Migration + def change + add_attachment :users, :avatar + end +end +``` + +### Vintage Syntax + +Vintage syntax (such as `t.has_attached_file` and `drop_attached_file`) is still supported in +Paperclip 3.x, but you're advised to update those migration files to use this new syntax. + +--- + Storage ------- +Paperclip ships with 3 storage adapters: + +* File Storage +* S3 Storage (via `aws-sdk-s3`) +* Fog Storage + +If you would like to use Paperclip with another storage, you can install these +gems along side with Paperclip: + +* [paperclip-azure](https://github.com/supportify/paperclip-azure) +* [paperclip-azure-storage](https://github.com/gmontard/paperclip-azure-storage) +* [paperclip-dropbox](https://github.com/janko-m/paperclip-dropbox) + +### Understanding Storage + The files that are assigned as attachments are, by default, placed in the directory specified by the `:path` option to `has_attached_file`. By default, this -location is `:rails_root/public/system/:attachment/:id/:style/:filename`. This -location was chosen because on standard Capistrano deployments, the -`public/system` directory is symlinked to the app's shared directory, meaning it -will survive between deployments. For example, using that `:path`, you may have a +location is `:rails_root/public/system/:class/:attachment/:id_partition/:style/:filename`. +This location was chosen because, on standard Capistrano deployments, the +`public/system` directory can be symlinked to the app's shared directory, meaning it +survives between deployments. For example, using that `:path`, you may have a file at - /data/myapp/releases/20081229172410/public/system/avatars/13/small/my_pic.png + /data/myapp/releases/20081229172410/public/system/users/avatar/000/000/013/small/my_pic.png _**NOTE**: This is a change from previous versions of Paperclip, but is overall a safer choice for the default file store._ You may also choose to store your files using Amazon's S3 service. To do so, include -the `aws-sdk` gem in your Gemfile: +the `aws-sdk-s3` gem in your Gemfile: - gem 'aws-sdk', '~> 1.3.4' +```ruby +gem 'aws-sdk-s3' +``` And then you can specify using S3 from `has_attached_file`. You can find more information about configuring and using S3 storage in -[the `Paperclip::Storage::S3` documentation](http://rubydoc.info/gems/paperclip/Paperclip/Storage/S3). +[the `Paperclip::Storage::S3` documentation](http://www.rubydoc.info/gems/paperclip/Paperclip/Storage/S3). Files on the local filesystem (and in the Rails app's public directory) will be available to the internet at large. If you require access control, it's @@ -189,57 +632,125 @@ both the `:path` and `:url` options in order to make sure the files are unavaila to the public. Both `:path` and `:url` allow the same set of interpolated variables. +--- + +IO Adapters +----------- + +When a file is uploaded or attached, it can be in one of a few different input +forms, from Rails' UploadedFile object to a StringIO to a Tempfile or even a +simple String that is a URL that points to an image. + +Paperclip will accept, by default, many of these sources. It also is capable of +handling even more with a little configuration. The IO Adapters that handle +images from non-local sources are not enabled by default. They can be enabled by +adding a line similar to the following into `config/initializers/paperclip.rb`: + +```ruby +Paperclip::DataUriAdapter.register +``` + +It's best to only enable a remote-loading adapter if you need it. Otherwise +there's a chance that someone can gain insight into your internal network +structure using it as a vector. + +The following adapters are *not* loaded by default: + +* `Paperclip::UriAdapter` - which accepts a `URI` instance. +* `Paperclip::HttpUrlProxyAdapter` - which accepts a `http` string. +* `Paperclip::DataUriAdapter` - which accepts a Base64-encoded `data:` string. + +--- + Post Processing --------------- Paperclip supports an extensible selection of post-processors. When you define a set of styles for an attachment, by default it is expected that those -"styles" are actually "thumbnails". However, you can do much more than just -thumbnail images. By defining a subclass of Paperclip::Processor, you can -perform any processing you want on the files that are attached. Any file in -your Rails app's lib/paperclip\_processors directory is automatically loaded by -paperclip, allowing you to easily define custom processors. You can specify a -processor with the :processors option to has\_attached\_file: +"styles" are actually "thumbnails." These are processed by +`Paperclip::Thumbnail`. For backward compatibility reasons you can pass either +a single geometry string, or an array containing a geometry and a format that +the file will be converted to, like so: - has_attached\_file :scan, :styles => { :text => { :quality => :better } }, - :processors => [:ocr] +```ruby +has_attached_file :avatar, styles: { thumb: ["32x32#", :png] } +``` -This would load the hypothetical class Paperclip::Ocr, which would have the -hash "{ :quality => :better }" passed to it along with the uploaded file. For -more information about defining processors, see Paperclip::Processor. +This will convert the "thumb" style to a 32x32 square in PNG format, regardless +of what was uploaded. If the format is not specified, it is kept the same (e.g. +JPGs will remain JPGs). `Paperclip::Thumbnail` uses ImageMagick to process +images; [ImageMagick's geometry documentation](http://www.imagemagick.org/script/command-line-processing.php#geometry) +has more information on the accepted style formats. -The default processor is Paperclip::Thumbnail. For backwards compatability -reasons, you can pass a single geometry string or an array containing a -geometry and a format, which the file will be converted to, like so: +For more fine-grained control of the conversion process, `source_file_options` and `convert_options` can be used to pass flags and settings directly to ImageMagick's powerful Convert tool, [documented here](https://www.imagemagick.org/script/convert.php). For example: - has_attached_file :avatar, :styles => { :thumb => ["32x32#", :png] } +```ruby +has_attached_file :image, styles: { regular: ['800x800>', :png]}, + source_file_options: { regular: "-density 96 -depth 8 -quality 85" }, + convert_options: { regular: "-posterize 3"} +``` -This will convert the "thumb" style to a 32x32 square in png format, regardless -of what was uploaded. If the format is not specified, it is kept the same (i.e. -jpgs will remain jpgs). For more information on the accepted style formats, see -[http://www.imagemagick.org/script/command-line-processing.php#geometry](here). +ImageMagick supports a number of environment variables for controlling its resource limits. For example, you can enforce memory or execution time limits by setting the following variables in your application's process environment: + +* `MAGICK_MEMORY_LIMIT=128MiB` +* `MAGICK_MAP_LIMIT=64MiB` +* `MAGICK_TIME_LIMIT=30` + +For a full list of variables and description, see [ImageMagick's resources documentation](http://www.imagemagick.org/script/resources.php). + +--- + +Custom Attachment Processors +------- + +You can write your own custom attachment processors to carry out tasks like +adding watermarks, compressing images, or encrypting files. Custom processors +must be defined within the `Paperclip` module, inherit from +`Paperclip::Processor` (see [`lib/paperclip/processor.rb`](https://github.com/thoughtbot/paperclip/blob/master/lib/paperclip/processor.rb)), +and implement a `make` method that returns a `File`. All files in your Rails +app's `lib/paperclip` and `lib/paperclip_processors` directories will be +automatically loaded by Paperclip. Processors are specified using the +`:processors` option to `has_attached_file`: + +```ruby +has_attached_file :scan, styles: { text: { quality: :better } }, + processors: [:ocr] +``` + +This would load the hypothetical class `Paperclip::Ocr`, and pass it the +options hash `{ quality: :better }`, along with the uploaded file. Multiple processors can be specified, and they will be invoked in the order -they are defined in the :processors array. Each successive processor will -be given the result of the previous processor's execution. All processors will -receive the same parameters, which are what you define in the :styles hash. -For example, assuming we had this definition: +they are defined in the `:processors` array. Each successive processor is given +the result from the previous processor. All processors receive the same +parameters, which are defined in the `:styles` hash. For example, assuming we +had this definition: - has_attached_file :scan, :styles => { :text => { :quality => :better } }, - :processors => [:rotator, :ocr] +```ruby +has_attached_file :scan, styles: { text: { quality: :better } }, + processors: [:rotator, :ocr] +``` -then both the :rotator processor and the :ocr processor would receive the -options "{ :quality => :better }". This parameter may not mean anything to one -or more or the processors, and they are expected to ignore it. +Both the `:rotator` processor and the `:ocr` processor would receive the +options `{ quality: :better }`. If a processor receives an option it doesn't +recognise, it's expected to ignore it. _NOTE: Because processors operate by turning the original attachment into the styles, no processors will be run if there are no styles defined._ If you're interested in caching your thumbnail's width, height and size in the -database, take a look at the [paperclip-meta](https://github.com/y8/paperclip-meta) gem. +database, take a look at the [paperclip-meta](https://github.com/teeparham/paperclip-meta) +gem. Also, if you're interested in generating the thumbnail on-the-fly, you might want -to look into the [attachment_on_the_fly](https://github.com/drpentode/Attachment-on-the-Fly) gem. +to look into the [attachment_on_the_fly](https://github.com/drpentode/Attachment-on-the-Fly) +gem. + +Paperclip's thumbnail generator (see [`lib/paperclip/thumbnail.rb`](lib/paperclip/thumbnail.rb)) +is implemented as a processor, and may be a good reference for writing your own +processors. + +--- Events ------ @@ -251,14 +762,28 @@ are called before and after the processing of each attachment), and the attachment-specific `before__post_process` and `after__post_process`. The callbacks are intended to be as close to normal ActiveRecord callbacks as possible, so if you return false (specifically -\- returning nil is not the same) in a before\_ filter, the post processing step -will halt. Returning false in an after\_ filter will not halt anything, but you +\- returning nil is not the same) in a `before_filter`, the post processing step +will halt. Returning false in an `after_filter` will not halt anything, but you can access the model and the attachment if necessary. -_NOTE: Post processing will not even *start* if the attachment is not valid -according to the validations. Your callbacks and processors will *only* be +_NOTE: Post processing will not even **start** if the attachment is not valid +according to the validations. Your callbacks and processors will **only** be called with valid attachments._ +```ruby +class Message < ActiveRecord::Base + has_attached_file :asset, styles: { thumb: "100x100#" } + + before_post_process :skip_for_audio + + def skip_for_audio + ! %w(audio/ogg application/ogg).include?(asset_content_type) + end +end +``` + +--- + URI Obfuscation --------------- @@ -267,53 +792,65 @@ publicly-available files. Example Usage: - has_attached_file :avatar, { - :url => "/system/:hash.:extension", - :hash_secret => "longSecretString" - } +```ruby +has_attached_file :avatar, { + url: "/system/:hash.:extension", + hash_secret: "longSecretString" +} +``` The `:hash` interpolation will be replaced with a unique hash made up of whatever is specified in `:hash_data`. The default value for `:hash_data` is `":class/:attachment/:id/:style/:updated_at"`. -`:hash_secret` is required, an exception will be raised if `:hash` is used without `:hash_secret` present. +`:hash_secret` is required - an exception will be raised if `:hash` is used without `:hash_secret` present. -For more on this feature read the author's own explanation. [https://github.com/thoughtbot/paperclip/pull/416](https://github.com/thoughtbot/paperclip/pull/416) +For more on this feature, read [the author's own explanation](https://github.com/thoughtbot/paperclip/pull/416) -MD5 Checksum / Fingerprint +Checksum / Fingerprint ------- -A MD5 checksum of the original file assigned will be placed in the model if it +A checksum of the original file assigned will be placed in the model if it has an attribute named fingerprint. Following the user model migration example -above, the migration would look like the following. +above, the migration would look like the following: - class AddAvatarFingerprintColumnToUser < ActiveRecord::Migration - def self.up - add_column :users, :avatar_fingerprint, :string - end +```ruby +class AddAvatarFingerprintColumnToUser < ActiveRecord::Migration + def up + add_column :users, :avatar_fingerprint, :string + end - def self.down - remove_column :users, :avatar_fingerprint - end - end + def down + remove_column :users, :avatar_fingerprint + end +end +``` -Custom Attachment Processors +The algorithm can be specified using a configuration option; it defaults to MD5 +for backwards compatibility with Paperclip 5 and earlier. + +```ruby +has_attached_file :some_attachment, adapter_options: { hash_digest: Digest::SHA256 } +``` + +Run `CLASS=User ATTACHMENT=avatar rake paperclip:refresh:fingerprints` after +changing the digest on existing attachments to update the fingerprints in the +database. + +File Preservation for Soft-Delete ------- -Custom attachment processors can be implemented and their only requirement is -to inherit from `Paperclip::Processor` (see `lib/paperclip/processor.rb`). -For example, when `:styles` are specified for an image attachment, the -thumbnail processor (see `lib/paperclip/thumbnail.rb`) is loaded without having -to specify it as a `:processor` parameter to `has_attached_file`. When any -other processor is defined it must be called out in the `:processors` -parameter if it is to be applied to the attachment. The thumbnail processor -uses the imagemagick `convert` command to do the work of resizing image -thumbnails. It would be easy to create a custom processor that watermarks -an image using imagemagick's `composite` command. Following the -implementation pattern of the thumbnail processor would be a way to implement a -watermark processor. All kinds of attachment processors can be created; -a few utility examples would be compression and encryption processors. +An option is available to preserve attachments in order to play nicely with soft-deleted models. (acts_as_paranoid, paranoia, etc.) + +```ruby +has_attached_file :some_attachment, { + preserve_files: true, +} +``` + +This will prevent ```some_attachment``` from being wiped out when the model gets destroyed, so it will still exist when the object is restored later. +--- Dynamic Configuration --------------------- @@ -325,20 +862,22 @@ allowing custom styles and processors to be applied for specific model instances, rather than applying defined styles and processors across all instances. -Dynamic Styles: +### Dynamic Styles: Imagine a user model that had different styles based on the role of the user. -Perhaps some users are bosses (e.g. a User model instance responds to #boss?) +Perhaps some users are bosses (e.g. a User model instance responds to `#boss?`) and merit a bigger avatar thumbnail than regular users. The configuration to determine what style parameters are to be used based on the user role might look as follows where a boss will receive a `300x300` thumbnail otherwise a `100x100` thumbnail will be created. - class User < ActiveRecord::Base - has_attached_file :avatar, :styles => lambda { |attachment| { :thumb => (attachment.instance.boss? ? "300x300>" : "100x100>") } - end +```ruby +class User < ActiveRecord::Base + has_attached_file :avatar, styles: lambda { |attachment| { thumb: (attachment.instance.boss? ? "300x300>" : "100x100>") } } +end +``` -Dynamic Processors: +### Dynamic Processors: Another contrived example is a user model that is aware of which file processors should be applied to it (beyond the implied `thumbnail` processor invoked when @@ -349,68 +888,164 @@ Presumably some users might return `[:thumbnail, :watermark]` for its processors, where a defined `watermark` processor is invoked after the `thumbnail` processor already defined by Paperclip. - class User < ActiveRecord::Base - has_attached_file :avatar, :processors => lambda { |instance| instance.processors } - attr_accessor :watermark - end +```ruby +class User < ActiveRecord::Base + has_attached_file :avatar, processors: lambda { |instance| instance.processors } + attr_accessor :processors +end +``` -Deploy ------- +--- + +Logging +---------- + +By default, Paperclip outputs logging according to your logger level. If you want to disable logging (e.g. during testing) add this into your environment's configuration: +```ruby +Your::Application.configure do +... + Paperclip.options[:log] = false +... +end +``` + +More information in the [rdocs](http://www.rubydoc.info/github/thoughtbot/paperclip/Paperclip.options) -Paperclip is aware of new attachment styles you have added in previous deploy. The only thing you should do after each deployment is to call +--- + +Deployment +---------- + +To make Capistrano symlink the `public/system` directory so that attachments +survive new deployments, set the `linked_dirs` option in your `config/deploy.rb` +file: + +```ruby +set :linked_dirs, fetch(:linked_dirs, []).push('public/system') +``` + +### Attachment Styles + +Paperclip is aware of new attachment styles you have added in previous deploys. The only thing you should do after each deployment is to call `rake paperclip:refresh:missing_styles`. It will store current attachment styles in `RAILS_ROOT/public/system/paperclip_attachments.yml` by default. You can change it by: - Paperclip.registered_attachments_styles_path = '/tmp/config/paperclip_attachments.yml' +```ruby +Paperclip.registered_attachments_styles_path = '/tmp/config/paperclip_attachments.yml' +``` Here is an example for Capistrano: - namespace :deploy do - desc "build missing paperclip styles" - task :build_missing_paperclip_styles, :roles => :app do - run "cd #{release_path}; RAILS_ENV=production bundle exec rake paperclip:refresh:missing_styles" +```ruby +namespace :paperclip do + desc "build missing paperclip styles" + task :build_missing_styles do + on roles(:app) do + within release_path do + with rails_env: fetch(:rails_env) do + execute :rake, "paperclip:refresh:missing_styles" + end end end + end +end - after("deploy:update_code", "deploy:build_missing_paperclip_styles") +after("deploy:compile_assets", "paperclip:build_missing_styles") +``` -Now you don't have to remember to refresh thumbnails in production everytime you add new style. -Unfortunately it does not work with dynamic styles - it just ignores them. +Now you don't have to remember to refresh thumbnails in production every time you add a new style. +Unfortunately, it does not work with dynamic styles - it just ignores them. -If you already have working app and don't want `rake paperclip:refresh:missing_styles` to refresh old pictures, you need to tell -Paperclip about existing styles. Simply create paperclip\_attachments.yml file by hand. For example: +If you already have a working app and don't want `rake paperclip:refresh:missing_styles` to refresh old pictures, you need to tell +Paperclip about existing styles. Simply create a `paperclip_attachments.yml` file by hand. For example: - class User < ActiveRecord::Base - has_attached_file :avatar, :styles => {:thumb => 'x100', :croppable => '600x600>', :big => '1000x1000>'} - end +```ruby +class User < ActiveRecord::Base + has_attached_file :avatar, styles: { thumb: 'x100', croppable: '600x600>', big: '1000x1000>' } +end - class Book < ActiveRecord::Base - has_attached_file :cover, :styles => {:small => 'x100', :large => '1000x1000>'} - has_attached_file :sample, :styles => {:thumb => 'x100'} - end +class Book < ActiveRecord::Base + has_attached_file :cover, styles: { small: 'x100', large: '1000x1000>' } + has_attached_file :sample, styles: { thumb: 'x100' } +end +``` Then in `RAILS_ROOT/public/system/paperclip_attachments.yml`: - --- - :User: - :avatar: - - :thumb - - :croppable - - :big - :Book: - :cover: - - :small - - :large - :sample: - - :thumb +```yml +--- +:User: + :avatar: + - :thumb + - :croppable + - :big +:Book: + :cover: + - :small + - :large + :sample: + - :thumb +``` + +--- Testing ------- Paperclip provides rspec-compatible matchers for testing attachments. See the -documentation on [Paperclip::Shoulda::Matchers](http://rubydoc.info/gems/paperclip/Paperclip/Shoulda/Matchers) +documentation on [Paperclip::Shoulda::Matchers](http://www.rubydoc.info/gems/paperclip/Paperclip/Shoulda/Matchers) for more information. +**Parallel Tests** + +Because of the default `path` for Paperclip storage, if you try to run tests in +parallel, you may find that files get overwritten because the same path is being +calculated for them in each test process. While this fix works for +parallel_tests, a similar concept should be used for any other mechanism for +running tests concurrently. + +```ruby +if ENV['PARALLEL_TEST_GROUPS'] + Paperclip::Attachment.default_options[:path] = ":rails_root/public/system/:rails_env/#{ENV['TEST_ENV_NUMBER'].to_i}/:class/:attachment/:id_partition/:filename" +else + Paperclip::Attachment.default_options[:path] = ":rails_root/public/system/:rails_env/:class/:attachment/:id_partition/:filename" +end +``` + +The important part here being the inclusion of `ENV['TEST_ENV_NUMBER']`, or a +similar mechanism for whichever parallel testing library you use. + +**Integration Tests** + +Using integration tests with FactoryBot may save multiple copies of +your test files within the app. To avoid this, specify a custom path in +the `config/environments/test.rb` like so: + +```ruby +Paperclip::Attachment.default_options[:path] = "#{Rails.root}/spec/test_files/:class/:id_partition/:style.:extension" +``` + +Then, make sure to delete that directory after the test suite runs by adding +this to `spec_helper.rb`. + +```ruby +config.after(:suite) do + FileUtils.rm_rf(Dir["#{Rails.root}/spec/test_files/"]) +end +``` + +**Example of test configuration with Factory Bot** + + +```ruby +FactoryBot.define do + factory :user do + avatar { File.new("#{Rails.root}/spec/support/fixtures/image.jpg") } + end +end +``` +--- + Contributing ------------ @@ -418,27 +1053,33 @@ If you'd like to contribute a feature or bugfix: Thanks! To make sure your fix/feature has a high chance of being included, please read the following guidelines: -1. Ask on the mailing list[http://groups.google.com/group/paperclip-plugin], or - post a new GitHub Issue[http://github.com/thoughtbot/paperclip/issues]. +1. Post a [pull request](https://github.com/thoughtbot/paperclip/compare/). 2. Make sure there are tests! We will not accept any patch that is not tested. It's a rare time when explicit tests aren't needed. If you have questions - about writing tests for paperclip, please ask the mailing list. + about writing tests for paperclip, please open a + [GitHub issue](https://github.com/thoughtbot/paperclip/issues/new). + +Please see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for more details on contributing and running test. -Please see CONTRIBUTING.md for details. +Thank you to all [the contributors](https://github.com/thoughtbot/paperclip/graphs/contributors)! -Credits +License ------- -![thoughtbot](http://thoughtbot.com/images/tm/logo.png) +Paperclip is Copyright © 2008-2017 thoughtbot, inc. It is free software, and may be +redistributed under the terms specified in the MIT-LICENSE file. -Paperclip is maintained and funded by [thoughtbot, inc](http://thoughtbot.com/community) +About thoughtbot +---------------- -Thank you to all [the contributors](https://github.com/thoughtbot/paperclip/contributors)! +![thoughtbot](http://presskit.thoughtbot.com/images/thoughtbot-logo-for-readmes.svg) +Paperclip is maintained and funded by thoughtbot. The names and logos for thoughtbot are trademarks of thoughtbot, inc. -License -------- - -Paperclip is Copyright © 2008-2011 thoughtbot. It is free software, and may be redistributed under the terms specified in the MIT-LICENSE file. +We love open source software! +See [our other projects][community] or +[hire us][hire] to design, develop, and grow your product. +[community]: https://thoughtbot.com/community?utm_source=github +[hire]: https://thoughtbot.com?utm_source=github diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..9c49a4654 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,17 @@ +Releasing paperclip + +1. Update `lib/paperclip/version.rb` file accordingly. +2. Update `NEWS` to reflect the changes since last release. +3. Commit changes. There shouldn’t be code changes, and thus CI doesn’t need to + run, you can then add “[ci skip]” to the commit message. +4. Tag the release: `git tag -m 'vVERSION' vVERSION` +5. Push changes: `git push --tags` +6. Build and publish the gem: + + ```bash + gem build paperclip.gemspec + gem push paperclip-VERSION.gem + ``` + +7. Announce the new release, making sure to say “thank you” to the contributors + who helped shape this version. diff --git a/Rakefile b/Rakefile index 9234ac601..ed7f61d6a 100644 --- a/Rakefile +++ b/Rakefile @@ -1,22 +1,25 @@ require 'bundler/gem_tasks' require 'appraisal' -require 'rake/testtask' +require 'rspec/core/rake_task' require 'cucumber/rake/task' desc 'Default: run unit tests.' -task :default => [:clean, 'appraisal:install', :all] +task :default => [:clean, :all] desc 'Test the paperclip plugin under all supported Rails versions.' task :all do |t| - exec('rake appraisal test cucumber') + if ENV['BUNDLE_GEMFILE'] + exec('rake spec && cucumber') + else + exec("rm -f gemfiles/*.lock") + Rake::Task["appraisal:gemfiles"].execute + Rake::Task["appraisal:install"].execute + exec('rake appraisal') + end end desc 'Test the paperclip plugin.' -Rake::TestTask.new(:test) do |t| - t.libs << 'lib' << 'profile' - t.pattern = 'test/**/*_test.rb' - t.verbose = true -end +RSpec::Core::RakeTask.new(:spec) desc 'Run integration test' Cucumber::Rake::Task.new do |t| diff --git a/UPGRADING b/UPGRADING new file mode 100644 index 000000000..a9e24018e --- /dev/null +++ b/UPGRADING @@ -0,0 +1,17 @@ +################################################## +# NOTE FOR UPGRADING FROM 4.3.0 OR EARLIER # +################################################## + +Paperclip is now compatible with aws-sdk-s3. + +If you are using S3 storage, aws-sdk-s3 requires you to make a few small +changes: + +* You must set the `s3_region` +* If you are explicitly setting permissions anywhere, such as in an initializer, + note that the format of the permissions changed from using an underscore to + using a hyphen. For example, `:public_read` needs to be changed to + `public-read`. + +For a walkthrough of upgrading from 4 to *5* (not 6) and aws-sdk >= 2.0 you can watch +http://rubythursday.com/episodes/ruby-snack-27-upgrade-paperclip-and-aws-sdk-in-prep-for-rails-5 diff --git a/cucumber/paperclip_steps.rb b/cucumber/paperclip_steps.rb deleted file mode 100644 index fac0c129f..000000000 --- a/cucumber/paperclip_steps.rb +++ /dev/null @@ -1,6 +0,0 @@ -When /^I attach an? "([^\"]*)" "([^\"]*)" file to an? "([^\"]*)" on S3$/ do |attachment, extension, model| - stub_paperclip_s3(model, attachment, extension) - attach_file attachment, - "features/support/paperclip/#{model.gsub(" ", "_").underscore}/#{attachment}.#{extension}" -end - diff --git a/features/basic_integration.feature b/features/basic_integration.feature index 5c5e677ee..9ad593ab4 100644 --- a/features/basic_integration.feature +++ b/features/basic_integration.feature @@ -7,40 +7,75 @@ Feature: Rails integration And I run a migration And I update my new user view to include the file upload field And I update my user view to include the attachment + And I allow the attachment to be submitted + + Scenario: Configure defaults for all attachments through Railtie + Given I add this snippet to config/application.rb: + """ + config.paperclip_defaults = { + :url => "/paperclip/custom/:attachment/:style/:filename", + :validate_media_type => false + } + """ + And I attach :attachment + And I start the rails application + When I go to the new user page + And I fill in "Name" with "something" + And I attach the file "spec/support/fixtures/animated.unknown" to "Attachment" + And I press "Submit" + Then I should see "Name: something" + And I should see an image with a path of "/paperclip/custom/attachments/original/animated.unknown" + And the file at "/paperclip/custom/attachments/original/animated.unknown" should be the same as "spec/support/fixtures/animated.unknown" + + Scenario: Add custom processors + Given I add a "test" processor in "lib/paperclip" + And I add a "cool" processor in "lib/paperclip_processors" + And I attach :attachment with: + """ + styles: { original: {} }, processors: [:test, :cool] + """ + And I start the rails application + When I go to the new user page + And I fill in "Name" with "something" + And I attach the file "spec/support/fixtures/5k.png" to "Attachment" + And I press "Submit" + Then I should see "Name: something" + And I should see an image with a path of "/paperclip/custom/attachments/original/5k.png" Scenario: Filesystem integration test - Given I add this snippet to the User model: + Given I attach :attachment with: """ - has_attached_file :attachment + :url => "/system/:attachment/:style/:filename" """ And I start the rails application When I go to the new user page And I fill in "Name" with "something" - And I attach the file "test/fixtures/5k.png" to "Attachment" + And I attach the file "spec/support/fixtures/5k.png" to "Attachment" And I press "Submit" Then I should see "Name: something" - And I should see an image with a path of "/system/attachments/1/original/5k.png" - And the file at "/system/attachments/1/original/5k.png" should be the same as "test/fixtures/5k.png" + And I should see an image with a path of "/system/attachments/original/5k.png" + And the file at "/system/attachments/original/5k.png" should be the same as "spec/support/fixtures/5k.png" Scenario: S3 Integration test - Given I add this snippet to the User model: + Given I attach :attachment with: """ - has_attached_file :attachment, - :storage => :s3, - :path => "/:attachment/:id/:style/:filename", - :s3_credentials => Rails.root.join("config/s3.yml") + :storage => :s3, + :path => "/:attachment/:style/:filename", + :s3_credentials => Rails.root.join("config/s3.yml"), + :styles => { :square => "100x100#" } """ And I write to "config/s3.yml" with: """ bucket: paperclip access_key_id: access_key secret_access_key: secret_key + s3_region: us-west-2 """ And I start the rails application When I go to the new user page And I fill in "Name" with "something" - And I attach the file "test/fixtures/5k.png" to "Attachment" on S3 + And I attach the file "spec/support/fixtures/5k.png" to "Attachment" on S3 And I press "Submit" Then I should see "Name: something" - And I should see an image with a path of "http://s3.amazonaws.com/paperclip/attachments/1/original/5k.png" - And the file at "http://s3.amazonaws.com/paperclip/attachments/1/original/5k.png" should be uploaded to S3 + And I should see an image with a path of "//s3.amazonaws.com/paperclip/attachments/original/5k.png" + And the file at "//s3.amazonaws.com/paperclip/attachments/original/5k.png" should be uploaded to S3 diff --git a/features/migration.feature b/features/migration.feature new file mode 100644 index 000000000..8e4b76ae1 --- /dev/null +++ b/features/migration.feature @@ -0,0 +1,70 @@ +Feature: Migration + + Background: + Given I generate a new rails application + And I write to "app/models/user.rb" with: + """ + class User < ActiveRecord::Base; end + """ + + Scenario: Vintage syntax + When I write to "db/migrate/01_add_attachment_to_users.rb" with: + """ + class AddAttachmentToUsers < ActiveRecord::Migration + def self.up + create_table :users do |t| + t.has_attached_file :avatar + end + end + + def self.down + drop_attached_file :users, :avatar + end + end + """ + And I run a migration + Then I should have attachment columns for "avatar" + + When I rollback a migration + Then I should not have attachment columns for "avatar" + + Scenario: New syntax with create_table + When I write to "db/migrate/01_add_attachment_to_users.rb" with: + """ + class AddAttachmentToUsers < ActiveRecord::Migration + def self.up + create_table :users do |t| + t.attachment :avatar + end + end + end + """ + And I run a migration + Then I should have attachment columns for "avatar" + + Scenario: New syntax outside of create_table + When I write to "db/migrate/01_create_users.rb" with: + """ + class CreateUsers < ActiveRecord::Migration + def self.up + create_table :users + end + end + """ + And I write to "db/migrate/02_add_attachment_to_users.rb" with: + """ + class AddAttachmentToUsers < ActiveRecord::Migration + def self.up + add_attachment :users, :avatar + end + + def self.down + remove_attachment :users, :avatar + end + end + """ + And I run a migration + Then I should have attachment columns for "avatar" + + When I rollback a migration + Then I should not have attachment columns for "avatar" diff --git a/features/rake_tasks.feature b/features/rake_tasks.feature index 140c29a37..2b85b3a68 100644 --- a/features/rake_tasks.feature +++ b/features/rake_tasks.feature @@ -5,10 +5,9 @@ Feature: Rake tasks And I run a rails generator to generate a "User" scaffold with "name:string" And I run a paperclip generator to add a paperclip "attachment" to the "User" model And I run a migration - And I add the paperclip rake task to a Rails 2.3 application - And I add this snippet to the User model: + And I attach :attachment with: """ - has_attached_file :attachment, :path => ":rails_root/public/system/:attachment/:style/:filename" + :path => ":rails_root/public/system/:attachment/:style/:filename" """ Scenario: Paperclip refresh thumbnails task diff --git a/features/step_definitions/attachment_steps.rb b/features/step_definitions/attachment_steps.rb index 005b59c32..c32d0ebaa 100644 --- a/features/step_definitions/attachment_steps.rb +++ b/features/step_definitions/attachment_steps.rb @@ -1,6 +1,6 @@ module AttachmentHelpers def fixture_path(filename) - File.expand_path("#{PROJECT_ROOT}/test/fixtures/#{filename}") + File.expand_path("#{PROJECT_ROOT}/spec/support/fixtures/#{filename}") end def attachment_path(filename) @@ -10,33 +10,37 @@ def attachment_path(filename) World(AttachmentHelpers) When /^I modify my attachment definition to:$/ do |definition| - write_file "app/models/user.rb", <<-FILE - class User < ActiveRecord::Base + content = cd(".") { File.read("app/models/user.rb") } + name = content[/has_attached_file :\w+/][/:\w+/] + content.gsub!(/has_attached_file.+end/m, <<-FILE) #{definition} + do_not_validate_attachment_file_type #{name} end FILE - in_current_dir { FileUtils.rm_rf ".rbx" } + + write_file "app/models/user.rb", content + cd(".") { FileUtils.rm_rf ".rbx" } end When /^I upload the fixture "([^"]*)"$/ do |filename| - run_simple %(bundle exec #{runner_command} "User.create!(:attachment => File.open('#{fixture_path(filename)}'))") + run_simple %(bundle exec rails runner "User.create!(:attachment => File.open('#{fixture_path(filename)}'))") end Then /^the attachment "([^"]*)" should have a dimension of (\d+x\d+)$/ do |filename, dimension| - in_current_dir do + cd(".") do geometry = `identify -format "%wx%h" "#{attachment_path(filename)}"`.strip - geometry.should == dimension + expect(geometry).to eq(dimension) end end Then /^the attachment "([^"]*)" should exist$/ do |filename| - in_current_dir do - File.exists?(attachment_path(filename)).should be + cd(".") do + expect(File.exist?(attachment_path(filename))).to be true end end When /^I swap the attachment "([^"]*)" with the fixture "([^"]*)"$/ do |attachment_filename, fixture_filename| - in_current_dir do + cd(".") do require 'fileutils' FileUtils.rm_f attachment_path(attachment_filename) FileUtils.cp fixture_path(fixture_filename), attachment_path(attachment_filename) @@ -44,22 +48,63 @@ class User < ActiveRecord::Base end Then /^the attachment should have the same content type as the fixture "([^"]*)"$/ do |filename| - in_current_dir do - require 'mime/types' - attachment_content_type = `bundle exec #{runner_command} "puts User.last.attachment_content_type"`.strip - attachment_content_type.should == MIME::Types.type_for(filename).first.content_type + cd(".") do + begin + # Use mime/types/columnar if available, for reduced memory usage + require "mime/types/columnar" + rescue LoadError + require "mime/types" + end + + attachment_content_type = `bundle exec rails runner "puts User.last.attachment_content_type"`.strip + expected = MIME::Types.type_for(filename).first.content_type + expect(attachment_content_type).to eq(expected) + end +end + +Then /^the attachment should have the same file name as the fixture "([^"]*)"$/ do |filename| + cd(".") do + attachment_file_name = `bundle exec rails runner "puts User.last.attachment_file_name"`.strip + expect(attachment_file_name).to eq(File.name(fixture_path(filename)).to_s) end end Then /^the attachment should have the same file size as the fixture "([^"]*)"$/ do |filename| - in_current_dir do - attachment_file_size = `bundle exec #{runner_command} "puts User.last.attachment_file_size"`.strip - attachment_file_size.should == File.size(fixture_path(filename)).to_s + cd(".") do + attachment_file_size = `bundle exec rails runner "puts User.last.attachment_file_size"`.strip + expect(attachment_file_size).to eq(File.size(fixture_path(filename)).to_s) end end Then /^the attachment file "([^"]*)" should (not )?exist$/ do |filename, not_exist| - in_current_dir do - check_file_presence([attachment_path(filename)], !not_exist) + cd(".") do + expect(attachment_path(filename)).not_to be_an_existing_file + end +end + +Then /^I should have attachment columns for "([^"]*)"$/ do |attachment_name| + cd(".") do + columns = eval(`bundle exec rails runner "puts User.columns.map{ |column| [column.name, column.type] }.inspect"`.strip) + expect_columns = [ + ["#{attachment_name}_file_name", :string], + ["#{attachment_name}_content_type", :string], + ["#{attachment_name}_file_size", :integer], + ["#{attachment_name}_updated_at", :datetime] + ] + expect(columns).to include(*expect_columns) + end +end + +Then /^I should not have attachment columns for "([^"]*)"$/ do |attachment_name| + cd(".") do + columns = eval(`bundle exec rails runner "puts User.columns.map{ |column| [column.name, column.type] }.inspect"`.strip) + expect_columns = [ + ["#{attachment_name}_file_name", :string], + ["#{attachment_name}_content_type", :string], + ["#{attachment_name}_file_size", :integer], + ["#{attachment_name}_updated_at", :datetime] + ] + + expect(columns).not_to include(*expect_columns) end end diff --git a/features/step_definitions/html_steps.rb b/features/step_definitions/html_steps.rb index aac19ae02..36f8f5db6 100644 --- a/features/step_definitions/html_steps.rb +++ b/features/step_definitions/html_steps.rb @@ -1,5 +1,5 @@ Then %r{I should see an image with a path of "([^"]*)"} do |path| - page.should have_css("img[src^='#{path}']") + expect(page).to have_css("img[src^='#{path}']") end Then %r{^the file at "([^"]*)" is the same as "([^"]*)"$} do |web_file, path| @@ -11,5 +11,5 @@ page.body end actual.force_encoding("UTF-8") if actual.respond_to?(:force_encoding) - actual.should == expected + expect(actual).to eq(expected) end diff --git a/features/step_definitions/rails_steps.rb b/features/step_definitions/rails_steps.rb index 25eaa18b2..0193a7255 100644 --- a/features/step_definitions/rails_steps.rb +++ b/features/step_definitions/rails_steps.rb @@ -1,63 +1,124 @@ Given /^I generate a new rails application$/ do steps %{ - When I run `bundle exec #{new_application_command} #{APP_NAME}` + When I successfully run `rails new #{APP_NAME} --skip-bundle` And I cd to "#{APP_NAME}" + } + + FileUtils.chdir("tmp/aruba/testapp/") + + steps %{ And I turn off class caching And I write to "Gemfile" with: """ source "http://rubygems.org" gem "rails", "#{framework_version}" - gem "sqlite3" + gem "sqlite3", :platform => [:ruby, :rbx] + gem "activerecord-jdbcsqlite3-adapter", :platform => :jruby + gem "jruby-openssl", :platform => :jruby gem "capybara" gem "gherkin" - gem "aws-sdk" + gem "aws-sdk-s3" + gem "racc", :platform => :rbx + gem "rubysl", :platform => :rbx """ + And I remove turbolinks + And I comment out lines that contain "action_mailer" in "config/environments/*.rb" + And I empty the application.js file And I configure the application to use "paperclip" from this project - And I reset Bundler environment variable - And I successfully run `bundle install --local` } + + FileUtils.chdir("../../..") +end + +Given "I allow the attachment to be submitted" do + cd(".") do + transform_file("app/controllers/users_controller.rb") do |content| + content.gsub("params.require(:user).permit(:name)", + "params.require(:user).permit!") + end + end +end + +Given "I remove turbolinks" do + cd(".") do + transform_file("app/assets/javascripts/application.js") do |content| + content.gsub("//= require turbolinks", "") + end + transform_file("app/views/layouts/application.html.erb") do |content| + content.gsub(', "data-turbolinks-track" => true', "") + end + end +end + +Given /^I comment out lines that contain "([^"]+)" in "([^"]+)"$/ do |contains, glob| + cd(".") do + Dir.glob(glob).each do |file| + transform_file(file) do |content| + content.gsub(/^(.*?#{contains}.*?)$/) { |line| "# #{line}" } + end + end + end +end + +Given /^I attach :attachment$/ do + attach_attachment("attachment") +end + +Given /^I attach :attachment with:$/ do |definition| + attach_attachment("attachment", definition) +end + +def attach_attachment(name, definition = nil) + snippet = "has_attached_file :#{name}" + if definition + snippet += ", \n" + snippet += definition + end + snippet += "\ndo_not_validate_attachment_file_type :#{name}\n" + cd(".") do + transform_file("app/models/user.rb") do |content| + content.sub(/end\Z/, "#{snippet}\nend") + end + end +end + +Given "I empty the application.js file" do + cd(".") do + transform_file("app/assets/javascripts/application.js") do |content| + "" + end + end end Given /^I run a rails generator to generate a "([^"]*)" scaffold with "([^"]*)"$/ do |model_name, attributes| - step %[I successfully run `bundle exec #{generator_command} scaffold #{model_name} #{attributes}`] + step %[I successfully run `rails generate scaffold #{model_name} #{attributes}`] end Given /^I run a paperclip generator to add a paperclip "([^"]*)" to the "([^"]*)" model$/ do |attachment_name, model_name| - step %[I successfully run `bundle exec #{generator_command} paperclip #{model_name} #{attachment_name}`] + step %[I successfully run `rails generate paperclip #{model_name} #{attachment_name}`] end Given /^I run a migration$/ do - step %[I successfully run `bundle exec rake db:migrate`] + step %[I successfully run `rake db:migrate --trace`] +end + +When /^I rollback a migration$/ do + step %[I successfully run `rake db:rollback STEPS=1 --trace`] end Given /^I update my new user view to include the file upload field$/ do - if framework_version?("3") - steps %{ - Given I overwrite "app/views/users/new.html.erb" with: - """ - <%= form_for @user, :html => { :multipart => true } do |f| %> - <%= f.label :name %> - <%= f.text_field :name %> - <%= f.label :attachment %> - <%= f.file_field :attachment %> - <%= submit_tag "Submit" %> - <% end %> - """ - } - else - steps %{ - Given I overwrite "app/views/users/new.html.erb" with: - """ - <% form_for @user, :html => { :multipart => true } do |f| %> - <%= f.label :name %> - <%= f.text_field :name %> - <%= f.label :attachment %> - <%= f.file_field :attachment %> - <%= submit_tag "Submit" %> - <% end %> - """ - } - end + steps %{ + Given I overwrite "app/views/users/new.html.erb" with: + """ + <%= form_for @user, :html => { :multipart => true } do |f| %> + <%= f.label :name %> + <%= f.text_field :name %> + <%= f.label :attachment %> + <%= f.file_field :attachment %> + <%= submit_tag "Submit" %> + <% end %> + """ + } end Given /^I update my user view to include the attachment$/ do @@ -72,16 +133,26 @@ Given /^I add this snippet to the User model:$/ do |snippet| file_name = "app/models/user.rb" - in_current_dir do + cd(".") do content = File.read(file_name) File.open(file_name, 'w') { |f| f << content.sub(/end\Z/, "#{snippet}\nend") } end end +Given /^I add this snippet to config\/application.rb:$/ do |snippet| + file_name = "config/application.rb" + cd(".") do + content = File.read(file_name) + File.open(file_name, 'w') {|f| f << content.sub(/class Application < Rails::Application.*$/, "class Application < Rails::Application\n#{snippet}\n")} + end +end + Given /^I start the rails application$/ do - in_current_dir do + cd(".") do + require "rails" require "./config/environment" - require "capybara/rails" + require "capybara" + Capybara.app = Rails.application end end @@ -89,8 +160,8 @@ Rails::Application.reload! end -When %r{I turn off class caching} do - in_current_dir do +When /^I turn off class caching$/ do + cd(".") do file = "config/environments/test.rb" config = IO.read(file) config.gsub!(%r{^\s*config.cache_classes.*$}, @@ -99,45 +170,15 @@ end end -Given /^I update my application to use Bundler$/ do - if framework_version?("2") - boot_config_template = File.read('features/support/fixtures/boot_config.txt') - preinitializer_template = File.read('features/support/fixtures/preinitializer.txt') - gemfile_template = File.read('features/support/fixtures/gemfile.txt') - in_current_dir do - content = File.read("config/boot.rb").sub(/Rails\.boot!/, boot_config_template) - File.open("config/boot.rb", "w") { |file| file.write(content) } - File.open("config/preinitializer.rb", "w") { |file| file.write(preinitializer_template) } - File.open("Gemfile", "w") { |file| file.write(gemfile_template.sub(/RAILS_VERSION/, framework_version)) } - end - end -end - -Given /^I add the paperclip rake task to a Rails 2.3 application$/ do - if framework_version?("2.3") - require 'fileutils' - source = File.expand_path('lib/tasks/paperclip.rake') - destination = in_current_dir { File.expand_path("lib/tasks") } - FileUtils.cp source, destination - append_to "Rakefile", "require 'paperclip'" - end -end - Then /^the file at "([^"]*)" should be the same as "([^"]*)"$/ do |web_file, path| expected = IO.read(path) - actual = if web_file.match %r{^https?://} - Net::HTTP.get(URI.parse(web_file)) - else - visit(web_file) - page.source - end - actual.force_encoding("UTF-8") if actual.respond_to?(:force_encoding) - actual.should == expected + actual = read_from_web(web_file) + expect(actual).to eq(expected) end When /^I configure the application to use "([^\"]+)" from this project$/ do |name| append_to_gemfile "gem '#{name}', :path => '#{PROJECT_ROOT}'" - steps %{And I run `bundle install --local`} + steps %{And I successfully run `bundle install --local`} end When /^I configure the application to use "([^\"]+)"$/ do |gem_name| @@ -156,27 +197,41 @@ comment_out_gem_in_gemfile gemname end -module FileHelpers - def append_to(path, contents) - in_current_dir do - File.open(path, "a") do |file| - file.puts - file.puts contents - end +Given(/^I add a "(.*?)" processor in "(.*?)"$/) do |processor, directory| + filename = "#{directory}/#{processor}.rb" + cd(".") do + FileUtils.mkdir_p directory + File.open(filename, "w") do |f| + f.write(<<-CLASS) + module Paperclip + class #{processor.capitalize} < Processor + def make + basename = File.basename(file.path, File.extname(file.path)) + dst_format = options[:format] ? ".\#{options[:format]}" : '' + + dst = Tempfile.new([basename, dst_format]) + dst.binmode + + convert(':src :dst', + src: File.expand_path(file.path), + dst: File.expand_path(dst.path) + ) + + dst + end + end + end + CLASS end end +end - def append_to_gemfile(contents) - append_to('Gemfile', contents) - end - - def comment_out_gem_in_gemfile(gemname) - in_current_dir do - gemfile = File.read("Gemfile") - gemfile.sub!(/^(\s*)(gem\s*['"]#{gemname})/, "\\1# \\2") - File.open("Gemfile", 'w'){ |file| file.write(gemfile) } +def transform_file(filename) + if File.exist?(filename) + content = File.read(filename) + File.open(filename, "w") do |f| + content = yield(content) + f.write(content) end end end - -World(FileHelpers) diff --git a/features/step_definitions/s3_steps.rb b/features/step_definitions/s3_steps.rb index 71340cc50..f27f3f1af 100644 --- a/features/step_definitions/s3_steps.rb +++ b/features/step_definitions/s3_steps.rb @@ -1,11 +1,11 @@ When /^I attach the file "([^"]*)" to "([^"]*)" on S3$/ do |file_path, field| - definition = User.attachment_definitions[field.downcase.to_sym] - path = "https://paperclip.s3.amazonaws.com#{definition[:path]}" + definition = Paperclip::AttachmentRegistry.definitions_for(User)[field.downcase.to_sym] + path = "https://paperclip.s3.us-west-2.amazonaws.com#{definition[:path]}" path.gsub!(':filename', File.basename(file_path)) path.gsub!(/:([^\/\.]+)/) do |match| "([^\/\.]+)" end - FakeWeb.register_uri(:put, Regexp.new(path), :body => "OK") + FakeWeb.register_uri(:put, Regexp.new(path), :body => "") step "I attach the file \"#{file_path}\" to \"#{field}\"" end diff --git a/features/step_definitions/web_steps.rb b/features/step_definitions/web_steps.rb index 463d4b260..5c11f981a 100644 --- a/features/step_definitions/web_steps.rb +++ b/features/step_definitions/web_steps.rb @@ -103,107 +103,5 @@ def with_scope(locator) end Then /^(?:|I )should see "([^"]*)"$/ do |text| - if page.respond_to? :should - page.should have_content(text) - else - assert page.has_content?(text) - end -end - -Then /^(?:|I )should see \/([^\/]*)\/$/ do |regexp| - regexp = Regexp.new(regexp) - - if page.respond_to? :should - page.should have_xpath('//*', :text => regexp) - else - assert page.has_xpath?('//*', :text => regexp) - end -end - -Then /^(?:|I )should not see "([^"]*)"$/ do |text| - if page.respond_to? :should - page.should have_no_content(text) - else - assert page.has_no_content?(text) - end -end - -Then /^(?:|I )should not see \/([^\/]*)\/$/ do |regexp| - regexp = Regexp.new(regexp) - - if page.respond_to? :should - page.should have_no_xpath('//*', :text => regexp) - else - assert page.has_no_xpath?('//*', :text => regexp) - end -end - -Then /^the "([^"]*)" field(?: within (.*))? should contain "([^"]*)"$/ do |field, parent, value| - with_scope(parent) do - field = find_field(field) - if field.value.respond_to? :should - field.value.should =~ /#{value}/ - else - assert_match(/#{value}/, field.value) - end - end -end - -Then /^the "([^"]*)" field(?: within (.*))? should not contain "([^"]*)"$/ do |field, parent, value| - with_scope(parent) do - field = find_field(field) - if field.value.respond_to? :should_not - field.value.should_not =~ /#{value}/ - else - assert_no_match(/#{value}/, field.value) - end - end -end - -Then /^the "([^"]*)" checkbox(?: within (.*))? should be checked$/ do |label, parent| - with_scope(parent) do - field_checked = find_field(label)['checked'] - if field_checked.respond_to? :should - field_checked.should be_true - else - assert field_checked - end - end -end - -Then /^the "([^"]*)" checkbox(?: within (.*))? should not be checked$/ do |label, parent| - with_scope(parent) do - field_checked = find_field(label)['checked'] - if field_checked.respond_to? :should - field_checked.should be_false - else - assert !field_checked - end - end -end - -Then /^(?:|I )should be on (.+)$/ do |page_name| - current_path = URI.parse(current_url).path - if current_path.respond_to? :should - current_path.should == path_to(page_name) - else - assert_equal path_to(page_name), current_path - end -end - -Then /^(?:|I )should have the following query string:$/ do |expected_pairs| - query = URI.parse(current_url).query - actual_params = query ? CGI.parse(query) : {} - expected_params = {} - expected_pairs.rows_hash.each_pair{|k,v| expected_params[k] = v.split(',')} - - if actual_params.respond_to? :should - actual_params.should == expected_params - else - assert_equal expected_params, actual_params - end -end - -Then /^show me the page$/ do - save_and_open_page + expect(page).to have_content(text) end diff --git a/features/support/env.rb b/features/support/env.rb index 25fae50ba..03bbd22e3 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -1,8 +1,12 @@ require 'aruba/cucumber' require 'capybara/cucumber' -require 'test/unit/assertions' -World(Test::Unit::Assertions) +require 'rspec/matchers' + +$CUCUMBER=1 + +World(RSpec::Matchers) Before do + aruba.config.command_launcher = ENV.fetch("DEBUG", nil) ? :debug : :spawn @aruba_timeout_seconds = 120 end diff --git a/features/support/fakeweb.rb b/features/support/fakeweb.rb index f92105b93..95ae8578c 100644 --- a/features/support/fakeweb.rb +++ b/features/support/fakeweb.rb @@ -1,3 +1,13 @@ require 'fake_web' FakeWeb.allow_net_connect = false + +module FakeWeb + class StubSocket + def read_timeout=(_ignored) + end + + def continue_timeout=(_ignored) + end + end +end diff --git a/features/support/file_helpers.rb b/features/support/file_helpers.rb new file mode 100644 index 000000000..15784bbd6 --- /dev/null +++ b/features/support/file_helpers.rb @@ -0,0 +1,34 @@ +module FileHelpers + def append_to(path, contents) + cd(".") do + File.open(path, "a") do |file| + file.puts + file.puts contents + end + end + end + + def append_to_gemfile(contents) + append_to('Gemfile', contents) + end + + def comment_out_gem_in_gemfile(gemname) + cd(".") do + gemfile = File.read("Gemfile") + gemfile.sub!(/^(\s*)(gem\s*['"]#{gemname})/, "\\1# \\2") + File.open("Gemfile", 'w'){ |file| file.write(gemfile) } + end + end + + def read_from_web(url) + file = if url.match %r{^https?://} + Net::HTTP.get(URI.parse(url)) + else + visit(url) + page.source + end + file.force_encoding("UTF-8") if file.respond_to?(:force_encoding) + end +end + +World(FileHelpers) diff --git a/features/support/fixtures/.boot_config.rb.swo b/features/support/fixtures/.boot_config.rb.swo deleted file mode 100644 index 79c322c44..000000000 Binary files a/features/support/fixtures/.boot_config.rb.swo and /dev/null differ diff --git a/features/support/fixtures/gemfile.txt b/features/support/fixtures/gemfile.txt index 44525a2e6..78995b4c1 100644 --- a/features/support/fixtures/gemfile.txt +++ b/features/support/fixtures/gemfile.txt @@ -2,4 +2,4 @@ source "http://rubygems.org" gem "rails", "RAILS_VERSION" gem "rdoc" -gem "sqlite3" +gem "sqlite3", "1.3.8" diff --git a/features/support/paths.rb b/features/support/paths.rb index 198d3f686..583776044 100644 --- a/features/support/paths.rb +++ b/features/support/paths.rb @@ -17,7 +17,7 @@ def path_to(page_name) page_name =~ /the (.*) page/ path_components = $1.split(/\s+/) self.send(path_components.push('path').join('_').to_sym) - rescue Object => e + rescue Object raise "Can't find mapping from \"#{page_name}\" to a path.\n" + "Now, go and add a mapping in #{__FILE__}" end diff --git a/features/support/rails.rb b/features/support/rails.rb index 06572017b..868af99c4 100644 --- a/features/support/rails.rb +++ b/features/support/rails.rb @@ -6,7 +6,8 @@ ENV['RAILS_ENV'] = 'test' Before do - ENV['BUNDLE_GEMFILE'] = File.join(Dir.pwd, ENV['BUNDLE_GEMFILE']) unless ENV['BUNDLE_GEMFILE'].start_with?(Dir.pwd) + gemfile = ENV['BUNDLE_GEMFILE'].to_s + ENV['BUNDLE_GEMFILE'] = File.join(Dir.pwd, gemfile) unless gemfile.start_with?(Dir.pwd) @framework_version = nil end @@ -31,16 +32,8 @@ def framework_version @framework_version ||= `rails -v`[/^Rails (.+)$/, 1] end - def new_application_command - framework_version?("3") ? "rails new" : "rails" - end - - def generator_command - framework_version?("3") ? "script/rails generate" : "script/generate" - end - - def runner_command - framework_version?("3") ? "script/rails runner" : "script/runner" + def framework_major_version + framework_version.split(".").first.to_i end end World(RailsCommandHelpers) diff --git a/gemfiles/4.2.gemfile b/gemfiles/4.2.gemfile new file mode 100644 index 000000000..7acc8eb32 --- /dev/null +++ b/gemfiles/4.2.gemfile @@ -0,0 +1,17 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "sqlite3", "~> 1.3.8", :platforms => :ruby +gem "pry" +gem "rails", "~> 4.2.0" + +group :development, :test do + gem "activerecord-import" + gem "mime-types" + gem "builder" + gem "rubocop", :require => false + gem "rspec" +end + +gemspec :path => "../" diff --git a/gemfiles/5.0.gemfile b/gemfiles/5.0.gemfile new file mode 100644 index 000000000..b29822d02 --- /dev/null +++ b/gemfiles/5.0.gemfile @@ -0,0 +1,17 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "sqlite3", "~> 1.3.8", :platforms => :ruby +gem "pry" +gem "rails", "~> 5.0.0" + +group :development, :test do + gem "activerecord-import" + gem "mime-types" + gem "builder" + gem "rubocop", :require => false + gem "rspec" +end + +gemspec :path => "../" diff --git a/gemfiles/rails2.gemfile b/gemfiles/rails2.gemfile deleted file mode 100644 index efe49766f..000000000 --- a/gemfiles/rails2.gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "jruby-openssl", :platform=>:jruby -gem "rails", "~> 2.3.14" -gem "paperclip", :path=>"../" - -gemspec :path=>"../" \ No newline at end of file diff --git a/gemfiles/rails3.gemfile b/gemfiles/rails3.gemfile deleted file mode 100644 index 2e2bfe7bc..000000000 --- a/gemfiles/rails3.gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "jruby-openssl", :platform=>:jruby -gem "rails", "~> 3.0.10" -gem "paperclip", :path=>"../" - -gemspec :path=>"../" \ No newline at end of file diff --git a/gemfiles/rails3_1.gemfile b/gemfiles/rails3_1.gemfile deleted file mode 100644 index 931f586cf..000000000 --- a/gemfiles/rails3_1.gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "jruby-openssl", :platform=>:jruby -gem "rails", "~> 3.1.0" -gem "paperclip", :path=>"../" - -gemspec :path=>"../" \ No newline at end of file diff --git a/gemfiles/rails3_2.gemfile b/gemfiles/rails3_2.gemfile deleted file mode 100644 index 38f1abf7d..000000000 --- a/gemfiles/rails3_2.gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "jruby-openssl", :platform=>:jruby -gem "rails", "~> 3.2.0" -gem "paperclip", :path=>"../" - -gemspec :path=>"../" \ No newline at end of file diff --git a/generators/paperclip/USAGE b/generators/paperclip/USAGE deleted file mode 100644 index 4ad7e9684..000000000 --- a/generators/paperclip/USAGE +++ /dev/null @@ -1,5 +0,0 @@ -Usage: - - script/generate paperclip Class attachment1 (attachment2 ...) - -This will create a migration that will add the proper columns to your class's table. diff --git a/generators/paperclip/paperclip_generator.rb b/generators/paperclip/paperclip_generator.rb deleted file mode 100644 index b5d3c2236..000000000 --- a/generators/paperclip/paperclip_generator.rb +++ /dev/null @@ -1,27 +0,0 @@ -class PaperclipGenerator < Rails::Generator::NamedBase - attr_accessor :attachments, :migration_name - - def initialize(args, options = {}) - super - @class_name, @attachments = args[0], args[1..-1] - end - - def manifest - file_name = generate_file_name - @migration_name = file_name.camelize - record do |m| - m.migration_template "paperclip_migration.rb.erb", - File.join('db', 'migrate'), - :migration_file_name => file_name - end - end - - private - - def generate_file_name - names = attachments.map{|a| a.underscore } - names = names[0..-2] + ["and", names[-1]] if names.length > 1 - "add_attachments_#{names.join("_")}_to_#{@class_name.underscore}" - end - -end diff --git a/generators/paperclip/templates/paperclip_migration.rb.erb b/generators/paperclip/templates/paperclip_migration.rb.erb deleted file mode 100644 index eebb0e502..000000000 --- a/generators/paperclip/templates/paperclip_migration.rb.erb +++ /dev/null @@ -1,19 +0,0 @@ -class <%= migration_name %> < ActiveRecord::Migration - def self.up -<% attachments.each do |attachment| -%> - add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_name, :string - add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_content_type, :string - add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_size, :integer - add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_updated_at, :datetime -<% end -%> - end - - def self.down -<% attachments.each do |attachment| -%> - remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_name - remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_content_type - remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_size - remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_updated_at -<% end -%> - end -end diff --git a/init.rb b/init.rb deleted file mode 100644 index 187e3047f..000000000 --- a/init.rb +++ /dev/null @@ -1,4 +0,0 @@ -require File.join(File.dirname(__FILE__), "lib", "paperclip") -require 'paperclip/railtie' - -Paperclip::Railtie.insert diff --git a/lib/generators/paperclip/paperclip_generator.rb b/lib/generators/paperclip/paperclip_generator.rb index 73e86fc26..e2465540a 100644 --- a/lib/generators/paperclip/paperclip_generator.rb +++ b/lib/generators/paperclip/paperclip_generator.rb @@ -13,13 +13,13 @@ def self.source_root end def generate_migration - migration_template "paperclip_migration.rb.erb", "db/migrate/#{migration_file_name}" + migration_template("paperclip_migration.rb.erb", + "db/migrate/#{migration_file_name}", + migration_version: migration_version) end - protected - def migration_name - "add_attachment_#{attachment_names.join("_")}_to_#{name.underscore}" + "add_attachment_#{attachment_names.join("_")}_to_#{name.underscore.pluralize}" end def migration_file_name @@ -30,4 +30,9 @@ def migration_class_name migration_name.camelize end + def migration_version + if Rails.version.start_with? "5" + "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" + end + end end diff --git a/lib/generators/paperclip/templates/paperclip_migration.rb.erb b/lib/generators/paperclip/templates/paperclip_migration.rb.erb index 44589d25c..f08315c3e 100644 --- a/lib/generators/paperclip/templates/paperclip_migration.rb.erb +++ b/lib/generators/paperclip/templates/paperclip_migration.rb.erb @@ -1,19 +1,15 @@ -class <%= migration_class_name %> < ActiveRecord::Migration +class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> def self.up + change_table :<%= table_name %> do |t| <% attachment_names.each do |attachment| -%> - add_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_file_name, :string - add_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_content_type, :string - add_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_file_size, :integer - add_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_updated_at, :datetime + t.attachment :<%= attachment %> <% end -%> + end end def self.down <% attachment_names.each do |attachment| -%> - remove_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_file_name - remove_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_content_type - remove_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_file_size - remove_column :<%= name.underscore.camelize.tableize %>, :<%= attachment %>_updated_at + remove_attachment :<%= table_name %>, :<%= attachment %> <% end -%> end end diff --git a/lib/paperclip.rb b/lib/paperclip.rb index d1bb0efe3..d44db3ce3 100644 --- a/lib/paperclip.rb +++ b/lib/paperclip.rb @@ -29,207 +29,85 @@ require 'digest' require 'tempfile' require 'paperclip/version' -require 'paperclip/upfile' -require 'paperclip/iostream' +require 'paperclip/geometry_parser_factory' +require 'paperclip/geometry_detector_factory' require 'paperclip/geometry' require 'paperclip/processor' +require 'paperclip/processor_helpers' +require 'paperclip/tempfile' require 'paperclip/thumbnail' +require 'paperclip/interpolations/plural_cache' require 'paperclip/interpolations' +require 'paperclip/tempfile_factory' require 'paperclip/style' require 'paperclip/attachment' -require 'paperclip/attachment_options' require 'paperclip/storage' -require 'paperclip/callback_compatibility' +require 'paperclip/callbacks' +require 'paperclip/file_command_content_type_detector' +require 'paperclip/media_type_spoof_detector' +require 'paperclip/content_type_detector' +require 'paperclip/glue' +require 'paperclip/errors' require 'paperclip/missing_attachment_styles' -require 'paperclip/railtie' +require 'paperclip/validators' +require 'paperclip/logger' +require 'paperclip/helpers' +require 'paperclip/has_attached_file' +require 'paperclip/attachment_registry' +require 'paperclip/filename_cleaner' +require 'paperclip/rails_environment' + +begin + # Use mime/types/columnar if available, for reduced memory usage + require "mime/types/columnar" +rescue LoadError + require "mime/types" +end + +require 'mimemagic' +require 'mimemagic/overlay' require 'logger' -require 'cocaine' +require 'terrapin' + +require 'paperclip/railtie' if defined?(Rails::Railtie) # The base module that gets included in ActiveRecord::Base. See the # documentation for Paperclip::ClassMethods for more useful information. module Paperclip - - class << self - # Provides configurability to Paperclip. The options available are: - # * whiny: Will raise an error if Paperclip cannot process thumbnails of - # an uploaded image. Defaults to true. - # * log: Logs progress to the Rails log. Uses ActiveRecord's logger, so honors - # log levels, etc. Defaults to true. - # * command_path: Defines the path at which to find the command line - # programs if they are not visible to Rails the system's search path. Defaults to - # nil, which uses the first executable found in the user's search path. - # * image_magick_path: Deprecated alias of command_path. - def options - @options ||= { - :whiny => true, - :image_magick_path => nil, - :command_path => nil, - :log => true, - :log_command => true, - :swallow_stderr => true - } - end - - def configure - yield(self) if block_given? - end - - def interpolates key, &block - Paperclip::Interpolations[key] = block - end - - # The run method takes the name of a binary to run, the arguments to that binary - # and some options: - # - # :command_path -> A $PATH-like variable that defines where to look for the binary - # on the filesystem. Colon-separated, just like $PATH. - # - # :expected_outcodes -> An array of integers that defines the expected exit codes - # of the binary. Defaults to [0]. - # - # :log_command -> Log the command being run when set to true (defaults to false). - # This will only log if logging in general is set to true as well. - # - # :swallow_stderr -> Set to true if you don't care what happens on STDERR. - # - def run(cmd, arguments = "", local_options = {}) - if options[:image_magick_path] - Paperclip.log("[DEPRECATION] :image_magick_path is deprecated and will be removed. Use :command_path instead") - end - command_path = options[:command_path] || options[:image_magick_path] - Cocaine::CommandLine.path = ( Cocaine::CommandLine.path ? [Cocaine::CommandLine.path, command_path ].flatten : command_path ) - local_options = local_options.merge(:logger => logger) if logging? && (options[:log_command] || local_options[:log_command]) - Cocaine::CommandLine.new(cmd, arguments, local_options).run - end - - def processor(name) #:nodoc: - @known_processors ||= {} - if @known_processors[name.to_s] - @known_processors[name.to_s] - else - name = name.to_s.camelize - load_processor(name) unless Paperclip.const_defined?(name) - processor = Paperclip.const_get(name) - @known_processors[name.to_s] = processor - end - end - - def load_processor(name) - if defined?(Rails.root) && Rails.root - require File.expand_path(Rails.root.join("lib", "paperclip_processors", "#{name.underscore}.rb")) - end - end - - def clear_processors! - @known_processors.try(:clear) - end - - # You can add your own processor via the Paperclip configuration. Normally - # Paperclip will load all processors from the - # Rails.root/lib/paperclip_processors directory, but here you can add any - # existing class using this mechanism. - # - # Paperclip.configure do |c| - # c.register_processor :watermarker, WatermarkingProcessor.new - # end - def register_processor(name, processor) - @known_processors ||= {} - @known_processors[name.to_s] = processor - end - - # Find all instances of the given Active Record model +klass+ with attachment +name+. - # This method is used by the refresh rake tasks. - def each_instance_with_attachment(klass, name) - unscope_method = class_for(klass).respond_to?(:unscoped) ? :unscoped : :with_exclusive_scope - class_for(klass).send(unscope_method) do - class_for(klass).find(:all, :order => 'id').each do |instance| - yield(instance) if instance.send(:"#{name}?") - end - end - end - - # Log a paperclip-specific line. This will logs to STDOUT - # by default. Set Paperclip.options[:log] to false to turn off. - def log message - logger.info("[paperclip] #{message}") if logging? - end - - def logger #:nodoc: - @logger ||= options[:logger] || Logger.new(STDOUT) - end - - def logger=(logger) - @logger = logger - end - - def logging? #:nodoc: - options[:log] - end - - def class_for(class_name) - # Ruby 1.9 introduces an inherit argument for Module#const_get and - # #const_defined? and changes their default behavior. - # https://github.com/rails/rails/blob/v3.0.9/activesupport/lib/active_support/inflector/methods.rb#L89 - if Module.method(:const_get).arity == 1 - class_name.split('::').inject(Object) do |klass, partial_class_name| - klass.const_defined?(partial_class_name) ? klass.const_get(partial_class_name) : klass.const_missing(partial_class_name) - end - else - class_name.split('::').inject(Object) do |klass, partial_class_name| - klass.const_defined?(partial_class_name) ? klass.const_get(partial_class_name, false) : klass.const_missing(partial_class_name) - end - end - rescue ArgumentError => e - # Sadly, we need to capture ArgumentError here because Rails 2.3.x - # ActiveSupport dependency management will try to the constant inherited - # from Object, and fail miserably with "Object is not missing constant X" error - # https://github.com/rails/rails/blob/v2.3.12/activesupport/lib/active_support/dependencies.rb#L124 - if e.message =~ /is not missing constant/ - raise NameError, "uninitialized constant #{class_name}" - else - raise e - end - end - - def check_for_url_clash(name,url,klass) - @names_url ||= {} - default_url = url || Attachment.default_options[:url] - if @names_url[name] && @names_url[name][:url] == default_url && @names_url[name][:class] != klass && @names_url[name][:url] !~ /:class/ - log("Duplicate URL for #{name} with #{default_url}. This will clash with attachment defined in #{@names_url[name][:class]} class") - end - @names_url[name] = {:url => default_url, :class => klass} - end - - def reset_duplicate_clash_check! - @names_url = nil - end + extend Helpers + extend Logger + extend ProcessorHelpers + + # Provides configurability to Paperclip. The options available are: + # * whiny: Will raise an error if Paperclip cannot process thumbnails of + # an uploaded image. Defaults to true. + # * log: Logs progress to the Rails log. Uses ActiveRecord's logger, so honors + # log levels, etc. Defaults to true. + # * command_path: Defines the path at which to find the command line + # programs if they are not visible to Rails the system's search path. Defaults to + # nil, which uses the first executable found in the user's search path. + # * use_exif_orientation: Whether to inspect EXIF data to determine an + # image's orientation. Defaults to true. + def self.options + @options ||= { + command_path: nil, + content_type_mappings: {}, + log: true, + log_command: true, + read_timeout: nil, + swallow_stderr: true, + use_exif_orientation: true, + whiny: true, + is_windows: Gem.win_platform? + } end - class PaperclipError < StandardError #:nodoc: + def self.io_adapters=(new_registry) + @io_adapters = new_registry end - class StorageMethodNotFound < PaperclipError - end - - class CommandNotFoundError < PaperclipError - end - - class NotIdentifiedByImageMagickError < PaperclipError #:nodoc: - end - - class InfiniteInterpolationError < PaperclipError #:nodoc: - end - - module Glue - def self.included base #:nodoc: - base.extend ClassMethods - base.class_attribute :attachment_definitions if base.respond_to?(:class_attribute) - if base.respond_to?(:set_callback) - base.send :include, Paperclip::CallbackCompatability::Rails3 - else - base.send :include, Paperclip::CallbackCompatability::Rails21 - end - end + def self.io_adapters + @io_adapters ||= Paperclip::AdapterRegistry.new end module ClassMethods @@ -242,15 +120,18 @@ module ClassMethods # called on it, the attachment will *not* be deleted until +save+ is called. See the # Paperclip::Attachment documentation for more specifics. There are a number of options # you can set to change the behavior of a Paperclip attachment: - # * +url+: The full URL of where the attachment is publically accessible. This can just + # * +url+: The full URL of where the attachment is publicly accessible. This can just # as easily point to a directory served directly through Apache as it can to an action # that can control permissions. You can specify the full domain and path, but usually # just an absolute path is sufficient. The leading slash *must* be included manually for # absolute paths. The default value is - # "/system/:attachment/:id/:style/:filename". See + # "/system/:class/:attachment/:id_partition/:style/:filename". See # Paperclip::Attachment#interpolate for more information on variable interpolaton. # :url => "/:class/:attachment/:id/:style_:filename" # :url => "http://some.other.host/stuff/:class/:id_:extension" + # Note: When using the +s3+ storage option, the +url+ option expects + # particular values. See the Paperclip::Storage::S3#url documentation for + # specifics. # * +default_url+: The URL that will be returned if there is no attachment assigned. # This field is interpolated just as the url is. The default value is # "/:attachment/:style/missing.png" @@ -268,12 +149,12 @@ module ClassMethods # :default_style => :normal # user.avatar.url # => "/avatars/23/normal_me.png" # * +keep_old_files+: Keep the existing attachment files (original + resized) from - # being automatically deleted when an attachment is cleared or updated. - # Defaults to +false+.# + # being automatically deleted when an attachment is cleared or updated. Defaults to +false+. + # * +preserve_files+: Keep the existing attachment files in all cases, even if the parent + # record is destroyed. Defaults to +false+. # * +whiny+: Will raise an error if Paperclip cannot post_process an uploaded file due # to a command line error. This will override the global setting for this attachment. - # Defaults to true. This option used to be called :whiny_thumbanils, but this is - # deprecated. + # Defaults to true. # * +convert_options+: When creating thumbnails, use this free-form options # array to pass in various convert command options. Typical options are "-strip" to # remove all Exif data from the image (save space for thumbnails and avatars) or @@ -313,180 +194,22 @@ module ClassMethods # "/assets/avatars/default_#{gender}.png" # end # end - def has_attached_file name, options = {} - include InstanceMethods - - if attachment_definitions.nil? - if respond_to?(:class_attribute) - self.attachment_definitions = {} - else - write_inheritable_attribute(:attachment_definitions, {}) - end - else - if respond_to?(:class_attribute) - self.attachment_definitions = self.attachment_definitions.dup - else - write_inheritable_attribute(:attachment_definitions, self.attachment_definitions.dup) - end - end - - attachment_definitions[name] = Paperclip::AttachmentOptions.new(options) - Paperclip.classes_with_attachments << self.name - Paperclip.check_for_url_clash(name,attachment_definitions[name][:url],self.name) - - after_save :save_attached_files - before_destroy :prepare_for_destroy - after_destroy :destroy_attached_files - - define_paperclip_callbacks :post_process, :"#{name}_post_process" - - define_method name do |*args| - a = attachment_for(name) - (args.length > 0) ? a.to_s(args.first) : a - end - - define_method "#{name}=" do |file| - attachment_for(name).assign(file) - end - - define_method "#{name}?" do - attachment_for(name).file? - end - - validates_each(name) do |record, attr, value| - attachment = record.attachment_for(name) - attachment.send(:flush_errors) - end - end - - # Places ActiveRecord-style validations on the size of the file assigned. The - # possible options are: - # * +in+: a Range of bytes (i.e. +1..1.megabyte+), - # * +less_than+: equivalent to :in => 0..options[:less_than] - # * +greater_than+: equivalent to :in => options[:greater_than]..Infinity - # * +message+: error message to display, use :min and :max as replacements - # * +if+: A lambda or name of an instance method. Validation will only - # be run if this lambda or method returns true. - # * +unless+: Same as +if+ but validates if lambda or method returns false. - def validates_attachment_size name, options = {} - min = options[:greater_than] || (options[:in] && options[:in].first) || 0 - max = options[:less_than] || (options[:in] && options[:in].last) || (1.0/0) - range = (min..max) - message = options[:message] || "must be between :min and :max bytes" - message = message.call if message.respond_to?(:call) - message = message.gsub(/:min/, min.to_s).gsub(/:max/, max.to_s) - - validates_inclusion_of :"#{name}_file_size", - :in => range, - :message => message, - :if => options[:if], - :unless => options[:unless], - :allow_nil => true - end - - # Adds errors if thumbnail creation fails. The same as specifying :whiny_thumbnails => true. - def validates_attachment_thumbnails name, options = {} - warn('[DEPRECATION] validates_attachment_thumbnail is deprecated. ' + - 'This validation is on by default and will be removed from future versions. ' + - 'If you wish to turn it off, supply :whiny => false in your definition.') - attachment_definitions[name][:whiny_thumbnails] = true - end - - # Places ActiveRecord-style validations on the presence of a file. - # Options: - # * +if+: A lambda or name of an instance method. Validation will only - # be run if this lambda or method returns true. - # * +unless+: Same as +if+ but validates if lambda or method returns false. - def validates_attachment_presence name, options = {} - message = options[:message] || :empty - validates_each :"#{name}_file_name" do |record, attr, value| - if_clause_passed = options[:if].nil? || (options[:if].respond_to?(:call) ? options[:if].call(record) != false : record.send(options[:if])) - unless_clause_passed = options[:unless].nil? || (options[:unless].respond_to?(:call) ? !!options[:unless].call(record) == false : !record.send(options[:unless])) - if if_clause_passed && unless_clause_passed && value.blank? - record.errors.add(name, message) - record.errors.add("#{name}_file_name", message) - end - end - end - - # Places ActiveRecord-style validations on the content type of the file - # assigned. The possible options are: - # * +content_type+: Allowed content types. Can be a single content type - # or an array. Each type can be a String or a Regexp. It should be - # noted that Internet Explorer uploads files with content_types that you - # may not expect. For example, JPEG images are given image/pjpeg and - # PNGs are image/x-png, so keep that in mind when determining how you - # match. Allows all by default. - # * +message+: The message to display when the uploaded file has an invalid - # content type. - # * +if+: A lambda or name of an instance method. Validation will only - # be run is this lambda or method returns true. - # * +unless+: Same as +if+ but validates if lambda or method returns false. - # NOTE: If you do not specify an [attachment]_content_type field on your - # model, content_type validation will work _ONLY upon assignment_ and - # re-validation after the instance has been reloaded will always succeed. - # You'll still need to have a virtual attribute (created by +attr_accessor+) - # name +[attachment]_content_type+ to be able to use this validator. - def validates_attachment_content_type name, options = {} - validation_options = options.dup - allowed_types = [validation_options[:content_type]].flatten - validates_each(:"#{name}_content_type", validation_options) do |record, attr, value| - if !allowed_types.any?{|t| t === value } && !(value.nil? || value.blank?) - if record.errors.method(:add).arity == -2 - message = options[:message] || "is not one of #{allowed_types.join(", ")}" - message = message.call if message.respond_to?(:call) - record.errors.add(:"#{name}_content_type", message) - else - record.errors.add(:"#{name}_content_type", :inclusion, :default => options[:message], :value => value) - end - end - end - end - - # Returns the attachment definitions defined by each call to - # has_attached_file. - def attachment_definitions - if respond_to?(:class_attribute) - self.attachment_definitions - else - read_inheritable_attribute(:attachment_definitions) - end + def has_attached_file(name, options = {}) + HasAttachedFile.define_on(self, name, options) end end - - module InstanceMethods #:nodoc: - def attachment_for name - @_paperclip_attachments ||= {} - @_paperclip_attachments[name] ||= Attachment.new(name, self, self.class.attachment_definitions[name]) - end - - def each_attachment - self.class.attachment_definitions.each do |name, definition| - yield(name, attachment_for(name)) - end - end - - def save_attached_files - Paperclip.log("Saving attachments.") - each_attachment do |name, attachment| - attachment.send(:save) - end - end - - def destroy_attached_files - Paperclip.log("Deleting attachments.") - each_attachment do |name, attachment| - attachment.send(:flush_deletes) - end - end - - def prepare_for_destroy - Paperclip.log("Scheduling attachments for deletion.") - each_attachment do |name, attachment| - attachment.send(:queue_existing_for_delete) - end - end - - end - end + +# This stuff needs to be run after Paperclip is defined. +require 'paperclip/io_adapters/registry' +require 'paperclip/io_adapters/abstract_adapter' +require 'paperclip/io_adapters/empty_string_adapter' +require 'paperclip/io_adapters/identity_adapter' +require 'paperclip/io_adapters/file_adapter' +require 'paperclip/io_adapters/stringio_adapter' +require 'paperclip/io_adapters/data_uri_adapter' +require 'paperclip/io_adapters/nil_adapter' +require 'paperclip/io_adapters/attachment_adapter' +require 'paperclip/io_adapters/uploaded_file_adapter' +require 'paperclip/io_adapters/uri_adapter' +require 'paperclip/io_adapters/http_url_proxy_adapter' diff --git a/lib/paperclip/attachment.rb b/lib/paperclip/attachment.rb index 40e98d091..d83bd9608 100644 --- a/lib/paperclip/attachment.rb +++ b/lib/paperclip/attachment.rb @@ -1,20 +1,21 @@ -# encoding: utf-8 require 'uri' require 'paperclip/url_generator' +require 'active_support/deprecation' +require 'active_support/core_ext/string/inflections' module Paperclip # The Attachment class manages the files for a given attachment. It saves # when the model saves, deletes when the model is destroyed, and processes # the file upon assignment. class Attachment - include IOStream - def self.default_options @default_options ||= { :convert_options => {}, :default_style => :original, :default_url => "/:attachment/:style/missing.png", + :escape_url => true, :restricted_characters => /[&$+,\/:;=?@<>\[\]\{\}\|\\\^~%# ]/, + :filename_cleaner => nil, :hash_data => ":class/:attachment/:id/:style/:updated_at", :hash_digest => "SHA1", :interpolator => Paperclip::Interpolations, @@ -25,20 +26,23 @@ def self.default_options :source_file_options => {}, :storage => :filesystem, :styles => {}, - :url => "/system/:attachment/:id/:style/:filename", + :url => "/system/:class/:attachment/:id_partition/:style/:filename", :url_generator => Paperclip::UrlGenerator, :use_default_time_zone => true, :use_timestamp => true, - :whiny => Paperclip.options[:whiny] || Paperclip.options[:whiny_thumbnails] + :whiny => Paperclip.options[:whiny] || Paperclip.options[:whiny_thumbnails], + :validate_media_type => true, + :adapter_options => { hash_digest: Digest::MD5 }, + :check_validity_before_processing => true } end attr_reader :name, :instance, :default_style, :convert_options, :queued_for_write, :whiny, - :options, :interpolator, :source_file_options, :whiny + :options, :interpolator, :source_file_options attr_accessor :post_processing # Creates an Attachment object. +name+ is the name of the attachment, - # +instance+ is the ActiveRecord object instance it's attached to, and + # +instance+ is the model object instance it's attached to, and # +options+ is the same as the hash passed to +has_attached_file+. # # Options include: @@ -46,9 +50,10 @@ def self.default_options # +url+ - a relative URL of the attachment. This is interpolated using +interpolator+ # +path+ - where on the filesystem to store the attachment. This is interpolated using +interpolator+ # +styles+ - a hash of options for processing the attachment. See +has_attached_file+ for the details - # +only_process+ - style args to be run through the post-processor. This defaults to the empty list + # +only_process+ - style args to be run through the post-processor. This defaults to the empty list (which is + # a special case that indicates all styles should be processed) # +default_url+ - a URL for the missing image - # +default_style+ - the style to use when don't specify an argument to e.g. #url, #path + # +default_style+ - the style to use when an argument is not specified e.g. #url, #path # +storage+ - the storage mechanism. Defaults to :filesystem # +use_timestamp+ - whether to append an anti-caching timestamp to image URLs. Defaults to true # +whiny+, +whiny_thumbnails+ - whether to raise when thumbnailing fails @@ -59,14 +64,17 @@ def self.default_options # +convert_options+ - flags passed to the +convert+ command for processing # +source_file_options+ - flags passed to the +convert+ command that controls how the file is read # +processors+ - classes that transform the attachment. Defaults to [:thumbnail] - # +preserve_files+ - whether to keep files on the filesystem when deleting to clearing the attachment. Defaults to false + # +preserve_files+ - whether to keep files on the filesystem when deleting or clearing the attachment. Defaults to false + # +filename_cleaner+ - An object that responds to #call(filename) that will strip unacceptable charcters from filename # +interpolator+ - the object used to interpolate filenames and URLs. Defaults to Paperclip::Interpolations # +url_generator+ - the object used to generate URLs, using the interpolator. Defaults to Paperclip::UrlGenerator + # +escape_url+ - Perform URI escaping to URLs. Defaults to true def initialize(name, instance, options = {}) - @name = name + @name = name.to_sym + @name_string = name.to_s @instance = instance - options = self.class.default_options.merge(options) + options = self.class.default_options.deep_merge(options) @options = options @post_processing = true @@ -75,7 +83,7 @@ def initialize(name, instance, options = {}) @errors = {} @dirty = false @interpolator = options[:interpolator] - @url_generator = options[:url_generator].new(self, @options) + @url_generator = options[:url_generator].new(self) @source_file_options = options[:source_file_options] @whiny = options[:whiny] @@ -83,47 +91,30 @@ def initialize(name, instance, options = {}) end # What gets called when you call instance.attachment = File. It clears - # errors, assigns attributes, and processes the file. It - # also queues up the previous file for deletion, to be flushed away on - # #save of its host. In addition to form uploads, you can also assign - # another Paperclip attachment: + # errors, assigns attributes, and processes the file. It also queues up the + # previous file for deletion, to be flushed away on #save of its host. In + # addition to form uploads, you can also assign another Paperclip + # attachment: # new_user.avatar = old_user.avatar - def assign uploaded_file + def assign(uploaded_file) + @file = Paperclip.io_adapters.for(uploaded_file, + @options[:adapter_options]) ensure_required_accessors! + ensure_required_validations! + + if @file.assignment? + clear(*only_process) - if uploaded_file.is_a?(Paperclip::Attachment) - uploaded_filename = uploaded_file.original_filename - uploaded_file = uploaded_file.to_file(:original) - close_uploaded_file = uploaded_file.respond_to?(:close) + if @file.nil? + nil + else + assign_attributes + post_process_file + reset_file_if_original_reprocessed + end else - instance_write(:uploaded_file, uploaded_file) if uploaded_file + nil end - - return nil unless valid_assignment?(uploaded_file) - - uploaded_file.binmode if uploaded_file.respond_to? :binmode - self.clear - - return nil if uploaded_file.nil? - - uploaded_filename ||= uploaded_file.original_filename - stores_fingerprint = @instance.respond_to?("#{name}_fingerprint".to_sym) - @queued_for_write[:original] = to_tempfile(uploaded_file) - instance_write(:file_name, cleanup_filename(uploaded_filename.strip)) - instance_write(:content_type, uploaded_file.content_type.to_s.strip) - instance_write(:file_size, uploaded_file.size.to_i) - instance_write(:fingerprint, generate_fingerprint(uploaded_file)) if stores_fingerprint - instance_write(:updated_at, Time.now) - - @dirty = true - - post_process(*@options[:only_process]) if post_processing - - # Reset the file size if the original file was reprocessed. - instance_write(:file_size, @queued_for_write[:original].size.to_i) - instance_write(:fingerprint, generate_fingerprint(@queued_for_write[:original])) if stores_fingerprint - ensure - uploaded_file.close if close_uploaded_file end # Returns the public URL of the attachment with a given style. This does @@ -148,9 +139,8 @@ def assign uploaded_file # # +#new(Paperclip::Attachment, options_hash)+ # +#for(style_name, options_hash)+ - def url(style_name = default_style, options = {}) - default_options = {:timestamp => @options[:use_timestamp], :escape => true} + def url(style_name = default_style, options = {}) if options == true || options == false # Backwards compatibility. @url_generator.for(style_name, default_options.merge(:timestamp => options)) else @@ -158,6 +148,20 @@ def url(style_name = default_style, options = {}) end end + def default_options + { + :timestamp => @options[:use_timestamp], + :escape => @options[:escape_url] + } + end + + # Alias to +url+ that allows using the expiring_url method provided by the cloud + # storage implementations, but keep using filesystem storage for development and + # testing. + def expiring_url(time = 3600, style_name = default_style) + url(style_name) + end + # Returns the path of the attachment as defined by the :path option. If the # file is stored in the filesystem the path refers to the path of the file # on disk. If the file is stored in S3, the path is the "key" part of the @@ -167,26 +171,50 @@ def path(style_name = default_style) path.respond_to?(:unescape) ? path.unescape : path end + # :nodoc: + def staged_path(style_name = default_style) + if staged? + @queued_for_write[style_name].path + end + end + + # :nodoc: + def staged? + ! @queued_for_write.empty? + end + # Alias to +url+ def to_s style_name = default_style url(style_name) end + def as_json(options = nil) + to_s((options && options[:style]) || default_style) + end + def default_style @options[:default_style] end def styles - styling_option = @options[:styles] - if styling_option.respond_to?(:call) || !@normalized_styles - @normalized_styles = ActiveSupport::OrderedHash.new - (styling_option.respond_to?(:call) ? styling_option.call(self) : styling_option).each do |name, args| - @normalized_styles[name] = Paperclip::Style.new(name, args.dup, self) + if @options[:styles].respond_to?(:call) || @normalized_styles.nil? + styles = @options[:styles] + styles = styles.call(self) if styles.respond_to?(:call) + + @normalized_styles = styles.dup + styles.each_pair do |name, options| + @normalized_styles[name.to_sym] = Paperclip::Style.new(name.to_sym, options.dup, self) end end @normalized_styles end + def only_process + only_process = @options[:only_process].dup + only_process = only_process.call(self) if only_process.respond_to?(:call) + only_process.map(&:to_sym) + end + def processors processing_option = @options[:processors] @@ -211,6 +239,10 @@ def dirty? # the instance's errors and returns false, cancelling the save. def save flush_deletes unless @options[:keep_old_files] + process = only_process + if process.any? && !process.include?(:original) + @queued_for_write.except!(:original) + end flush_writes @dirty = false true @@ -219,20 +251,22 @@ def save # Clears out the attachment. Has the same effect as previously assigning # nil to the attachment. Does NOT save. If you wish to clear AND save, # use #destroy. - def clear - queue_existing_for_delete - @queued_for_write = {} - @errors = {} + def clear(*styles_to_clear) + if styles_to_clear.any? + queue_some_for_delete(*styles_to_clear) + else + queue_all_for_delete + @queued_for_write = {} + @errors = {} + end end # Destroys the attachment. Has the same effect as previously assigning # nil to the attachment *and saving*. This is permanent. If you wish to # wipe out the existing attachment but not save, use #clear. def destroy - unless @options[:preserve_files] - clear - save - end + clear + save end # Returns the uploaded file if present. @@ -252,16 +286,10 @@ def size instance_read(:file_size) || (@queued_for_write[:original] && @queued_for_write[:original].size) end - # Returns the hash of the file as originally assigned, and lives in the - # _fingerprint attribute of the model. + # Returns the fingerprint of the file, if one's defined. The fingerprint is + # stored in the _fingerprint attribute of the model. def fingerprint - if instance_read(:fingerprint) - instance_read(:fingerprint) - elsif @instance.respond_to?("#{name}_fingerprint".to_sym) - @queued_for_write[:original] && generate_fingerprint(@queued_for_write[:original]) - else - nil - end + instance_read(:fingerprint) end # Returns the content_type of the file as originally assigned, and lives @@ -270,6 +298,15 @@ def content_type instance_read(:content_type) end + # Returns the creation time of the file as originally assigned, and + # lives in the _created_at attribute of the model. + def created_at + if able_to_store_created_at? + time = instance_read(:created_at) + time && time.to_f.to_i + end + end + # Returns the last modified time of the file as originally assigned, and # lives in the _updated_at attribute of the model. def updated_at @@ -292,80 +329,69 @@ def hash_key(style_name = default_style) OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@options[:hash_digest]).new, @options[:hash_secret], data) end - def generate_fingerprint(source) - if source.respond_to?(:path) && source.path && !source.path.blank? - Digest::MD5.file(source.path).to_s - else - data = source.read - source.rewind if source.respond_to?(:rewind) - Digest::MD5.hexdigest(data) - end - end - - # Paths and URLs can have a number of variables interpolated into them - # to vary the storage location based on name, id, style, class, etc. - # This method is a deprecated access into supplying and retrieving these - # interpolations. Future access should use either Paperclip.interpolates - # or extend the Paperclip::Interpolations module directly. - def self.interpolations - warn('[DEPRECATION] Paperclip::Attachment.interpolations is deprecated ' + - 'and will be removed from future versions. ' + - 'Use Paperclip.interpolates instead') - Paperclip::Interpolations - end - - # This method really shouldn't be called that often. It's expected use is + # This method really shouldn't be called that often. Its expected use is # in the paperclip:refresh rake task and that's it. It will regenerate all # thumbnails forcefully, by reobtaining the original file and going through # the post-process again. + # NOTE: Calling reprocess WILL NOT delete existing files. This is due to + # inconsistencies in timing of S3 commands. It's possible that calling + # #reprocess! will lose data if the files are not kept. def reprocess!(*style_args) - new_original = Tempfile.new("paperclip-reprocess") - new_original.binmode - if old_original = to_file(:original) - new_original.write( old_original.respond_to?(:get) ? old_original.get : old_original.read ) - new_original.rewind - - @queued_for_write = { :original => new_original } - instance_write(:updated_at, Time.now) - post_process(*style_args) - - old_original.close if old_original.respond_to?(:close) - old_original.unlink if old_original.respond_to?(:unlink) + saved_flags = @options.slice( + :only_process, + :preserve_files, + :check_validity_before_processing + ) + @options[:only_process] = style_args + @options[:preserve_files] = true + @options[:check_validity_before_processing] = false + begin + assign(self) save - else - true + instance.save + rescue Errno::EACCES => e + warn "#{e} - skipping file." + false + ensure + @options.merge!(saved_flags) end - rescue Errno::EACCES => e - warn "#{e} - skipping file" - false end # Returns true if a file has been assigned. def file? - !original_filename.blank? + original_filename.present? end alias :present? :file? + def blank? + not present? + end + + # Determines whether the instance responds to this attribute. Used to prevent + # calculations on fields we won't even store. + def instance_respond_to?(attr) + instance.respond_to?(:"#{name}_#{attr}") + end + # Writes the attachment-specific attribute on the instance. For example, # instance_write(:file_name, "me.jpg") will write "me.jpg" to the instance's # "avatar_file_name" field (assuming the attachment is called avatar). def instance_write(attr, value) - setter = :"#{name}_#{attr}=" - responds = instance.respond_to?(setter) - self.instance_variable_set("@_#{setter.to_s.chop}", value) - instance.send(setter, value) if responds || attr.to_s == "file_name" + setter = :"#{@name_string}_#{attr}=" + if instance.respond_to?(setter) + instance.send(setter, value) + end end # Reads the attachment-specific attribute on the instance. See instance_write # for more details. def instance_read(attr) - getter = :"#{name}_#{attr}" - responds = instance.respond_to?(getter) - cached = self.instance_variable_get("@_#{getter}") - return cached if cached - instance.send(getter) if responds || attr.to_s == "file_name" + getter = :"#{@name_string}_#{attr}" + if instance.respond_to?(getter) + instance.send(getter) + end end private @@ -374,10 +400,24 @@ def path_option @options[:path].respond_to?(:call) ? @options[:path].call(self) : @options[:path] end + def active_validator_classes + @instance.class.validators.map(&:class) + end + + def missing_required_validator? + (active_validator_classes.flat_map(&:ancestors) & Paperclip::REQUIRED_VALIDATORS).empty? + end + + def ensure_required_validations! + if missing_required_validator? + raise Paperclip::Errors::MissingRequiredValidatorError + end + end + def ensure_required_accessors! #:nodoc: %w(file_name).each do |field| - unless @instance.respond_to?("#{name}_#{field}") && @instance.respond_to?("#{name}_#{field}=") - raise PaperclipError.new("#{@instance.class} model missing required attr_accessor for '#{name}_#{field}'") + unless @instance.respond_to?("#{@name_string}_#{field}") && @instance.respond_to?("#{@name_string}_#{field}=") + raise Paperclip::Error.new("#{@instance.class} model missing required attr_accessor for '#{@name_string}_#{field}'") end end end @@ -386,33 +426,83 @@ def log message #:nodoc: Paperclip.log(message) end - def valid_assignment? file #:nodoc: - file.nil? || (file.respond_to?(:original_filename) && file.respond_to?(:content_type)) - end - def initialize_storage #:nodoc: storage_class_name = @options[:storage].to_s.downcase.camelize begin storage_module = Paperclip::Storage.const_get(storage_class_name) rescue NameError - raise StorageMethodNotFound, "Cannot load storage module '#{storage_class_name}'" + raise Errors::StorageMethodNotFound, "Cannot load storage module '#{storage_class_name}'" end self.extend(storage_module) end - def extra_options_for(style) #:nodoc: - all_options = @options[:convert_options][:all] - all_options = all_options.call(instance) if all_options.respond_to?(:call) - style_options = @options[:convert_options][style] - style_options = style_options.call(instance) if style_options.respond_to?(:call) + def assign_attributes + @queued_for_write[:original] = @file + assign_file_information + assign_fingerprint { @file.fingerprint } + assign_timestamps + end - [ style_options, all_options ].compact.join(" ") + def assign_file_information + instance_write(:file_name, cleanup_filename(@file.original_filename)) + instance_write(:content_type, @file.content_type.to_s.strip) + instance_write(:file_size, @file.size) + end + + def assign_fingerprint + if instance_respond_to?(:fingerprint) + instance_write(:fingerprint, yield) + end + end + + def assign_timestamps + if has_enabled_but_unset_created_at? + instance_write(:created_at, Time.now) + end + + instance_write(:updated_at, Time.now) + end + + def post_process_file + dirty! + + if post_processing + post_process(*only_process) + end + end + + def dirty! + @dirty = true + end + + def reset_file_if_original_reprocessed + instance_write(:file_size, @queued_for_write[:original].size) + assign_fingerprint { @queued_for_write[:original].fingerprint } + reset_updater + end + + def reset_updater + if instance.respond_to?(updater) + instance.send(updater) + end + end + + def updater + :"#{name}_file_name_will_change!" + end + + def extra_options_for(style) #:nodoc: + process_options(:convert_options, style) end def extra_source_file_options_for(style) #:nodoc: - all_options = @options[:source_file_options][:all] + process_options(:source_file_options, style) + end + + def process_options(options_type, style) #:nodoc: + all_options = @options[options_type][:all] all_options = all_options.call(instance) if all_options.respond_to?(:call) - style_options = @options[:source_file_options][style] + style_options = @options[options_type][style] style_options = style_options.call(instance) if style_options.respond_to?(:call) [ style_options, all_options ].compact.join(" ") @@ -420,9 +510,12 @@ def extra_source_file_options_for(style) #:nodoc: def post_process(*style_args) #:nodoc: return if @queued_for_write[:original].nil? + instance.run_paperclip_callbacks(:post_process) do instance.run_paperclip_callbacks(:"#{name}_post_process") do - post_process_styles(*style_args) + if !@options[:check_validity_before_processing] || !instance.errors.any? + post_process_styles(*style_args) + end end end end @@ -437,12 +530,26 @@ def post_process_styles(*style_args) #:nodoc: def post_process_style(name, style) #:nodoc: begin raise RuntimeError.new("Style #{name} has no processors defined.") if style.processors.blank? - @queued_for_write[name] = style.processors.inject(@queued_for_write[:original]) do |file, processor| - Paperclip.processor(processor).make(file, style.processor_options, self) + intermediate_files = [] + original = @queued_for_write[:original] + + @queued_for_write[name] = style.processors. + reduce(original) do |file, processor| + file = Paperclip.processor(processor).make(file, style.processor_options, self) + intermediate_files << file unless file == @queued_for_write[:original] + file end - rescue PaperclipError => e + + unadapted_file = @queued_for_write[name] + @queued_for_write[name] = Paperclip.io_adapters. + for(@queued_for_write[name], @options[:adapter_options]) + unadapted_file.close if unadapted_file.respond_to?(:close) + @queued_for_write[name] + rescue Paperclip::Errors::NotIdentifiedByImageMagickError => e log("An error was received while processing: #{e.inspect}") (@errors[:processing] ||= []) << e.message if @options[:whiny] + ensure + unlink_files(intermediate_files) end end @@ -454,15 +561,24 @@ def interpolate(pattern, style_name = default_style) #:nodoc: interpolator.interpolate(pattern, self, style_name) end - def queue_existing_for_delete #:nodoc: - return if @options[:preserve_files] || !file? - @queued_for_delete += [:original, *styles.keys].uniq.map do |style| + def queue_some_for_delete(*styles) + @queued_for_delete += styles.uniq.map do |style| path(style) if exists?(style) end.compact + end + + def queue_all_for_delete #:nodoc: + return if !file? + unless @options[:preserve_files] + @queued_for_delete += [:original, *styles.keys].uniq.map do |style| + path(style) if exists?(style) + end.compact + end instance_write(:file_name, nil) instance_write(:content_type, nil) instance_write(:file_size, nil) instance_write(:fingerprint, nil) + instance_write(:created_at, nil) if has_enabled_but_unset_created_at? instance_write(:updated_at, nil) end @@ -472,18 +588,37 @@ def flush_errors #:nodoc: end end - # called by storage after the writes are flushed and before @queued_for_writes is cleared + # called by storage after the writes are flushed and before @queued_for_write is cleared def after_flush_writes - @queued_for_write.each do |style, file| + unlink_files(@queued_for_write.values) + end + + def unlink_files(files) + Array(files).each do |file| file.close unless file.closed? file.unlink if file.respond_to?(:unlink) && file.path.present? && File.exist?(file.path) end end + # You can either specifiy :restricted_characters or you can define your own + # :filename_cleaner object. This object needs to respond to #call and takes + # the filename that will be cleaned. It should return the cleaned filename. + def filename_cleaner + @options[:filename_cleaner] || FilenameCleaner.new(@options[:restricted_characters]) + end + def cleanup_filename(filename) - if @options[:restricted_characters] - filename.gsub(@options[:restricted_characters], '_') - end + filename_cleaner.call(filename) + end + + # Check if attachment database table has a created_at field + def able_to_store_created_at? + @instance.respond_to?("#{name}_created_at".to_sym) + end + + # Check if attachment database table has a created_at field which is not yet set + def has_enabled_but_unset_created_at? + able_to_store_created_at? && !instance_read(:created_at) end end end diff --git a/lib/paperclip/attachment_options.rb b/lib/paperclip/attachment_options.rb deleted file mode 100644 index 2d05ff84a..000000000 --- a/lib/paperclip/attachment_options.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Paperclip - class AttachmentOptions < Hash - def initialize(options) - options = {:validations => []}.merge(options) - options.each do |k, v| - self.[]=(k, v) - end - end - end -end diff --git a/lib/paperclip/attachment_registry.rb b/lib/paperclip/attachment_registry.rb new file mode 100644 index 000000000..e64d2d2e1 --- /dev/null +++ b/lib/paperclip/attachment_registry.rb @@ -0,0 +1,60 @@ +require 'singleton' + +module Paperclip + class AttachmentRegistry + include Singleton + + def self.register(klass, attachment_name, attachment_options) + instance.register(klass, attachment_name, attachment_options) + end + + def self.clear + instance.clear + end + + def self.names_for(klass) + instance.names_for(klass) + end + + def self.each_definition(&block) + instance.each_definition(&block) + end + + def self.definitions_for(klass) + instance.definitions_for(klass) + end + + def initialize + clear + end + + def register(klass, attachment_name, attachment_options) + @attachments ||= {} + @attachments[klass] ||= {} + @attachments[klass][attachment_name] = attachment_options + end + + def clear + @attachments = Hash.new { |h,k| h[k] = {} } + end + + def names_for(klass) + @attachments[klass].keys + end + + def each_definition + @attachments.each do |klass, attachments| + attachments.each do |name, options| + yield klass, name, options + end + end + end + + def definitions_for(klass) + parent_classes = klass.ancestors.reverse + parent_classes.each_with_object({}) do |ancestor, inherited_definitions| + inherited_definitions.deep_merge! @attachments[ancestor] + end + end + end +end diff --git a/lib/paperclip/callback_compatibility.rb b/lib/paperclip/callback_compatibility.rb deleted file mode 100644 index 9a34882e2..000000000 --- a/lib/paperclip/callback_compatibility.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Paperclip - module CallbackCompatability - module Rails21 - def self.included(base) - base.extend(Defining) - base.send(:include, Running) - end - - module Defining - def define_paperclip_callbacks(*args) - args.each do |callback| - define_callbacks("before_#{callback}") - define_callbacks("after_#{callback}") - end - end - end - - module Running - def run_paperclip_callbacks(callback, opts = nil, &blk) - # The overall structure of this isn't ideal since after callbacks run even if - # befores return false. But this is how rails 3's callbacks work, unfortunately. - if run_callbacks(:"before_#{callback}"){ |result, object| result == false } != false - blk.call - end - run_callbacks(:"after_#{callback}"){ |result, object| result == false } - end - end - end - - module Rails3 - def self.included(base) - base.extend(Defining) - base.send(:include, Running) - end - - module Defining - def define_paperclip_callbacks(*callbacks) - define_callbacks *[callbacks, {:terminator => "result == false"}].flatten - callbacks.each do |callback| - eval <<-end_callbacks - def before_#{callback}(*args, &blk) - set_callback(:#{callback}, :before, *args, &blk) - end - def after_#{callback}(*args, &blk) - set_callback(:#{callback}, :after, *args, &blk) - end - end_callbacks - end - end - end - - module Running - def run_paperclip_callbacks(callback, opts = nil, &block) - run_callbacks(callback, opts, &block) - end - end - - end - - end -end diff --git a/lib/paperclip/callbacks.rb b/lib/paperclip/callbacks.rb new file mode 100644 index 000000000..53a436857 --- /dev/null +++ b/lib/paperclip/callbacks.rb @@ -0,0 +1,42 @@ +module Paperclip + module Callbacks + def self.included(base) + base.extend(Defining) + base.send(:include, Running) + end + + module Defining + def define_paperclip_callbacks(*callbacks) + define_callbacks(*[callbacks, { terminator: hasta_la_vista_baby }].flatten) + callbacks.each do |callback| + eval <<-end_callbacks + def before_#{callback}(*args, &blk) + set_callback(:#{callback}, :before, *args, &blk) + end + def after_#{callback}(*args, &blk) + set_callback(:#{callback}, :after, *args, &blk) + end + end_callbacks + end + end + + private + + def hasta_la_vista_baby + lambda do |_, result| + if result.respond_to?(:call) + result.call == false + else + result == false + end + end + end + end + + module Running + def run_paperclip_callbacks(callback, &block) + run_callbacks(callback, &block) + end + end + end +end diff --git a/lib/paperclip/content_type_detector.rb b/lib/paperclip/content_type_detector.rb new file mode 100644 index 000000000..b98779551 --- /dev/null +++ b/lib/paperclip/content_type_detector.rb @@ -0,0 +1,80 @@ +module Paperclip + class ContentTypeDetector + # The content-type detection strategy is as follows: + # + # 1. Blank/Empty files: If there's no filepath or the file is empty, + # provide a sensible default (application/octet-stream or inode/x-empty) + # + # 2. Calculated match: Return the first result that is found by both the + # `file` command and MIME::Types. + # + # 3. Standard types: Return the first standard (without an x- prefix) entry + # in MIME::Types + # + # 4. Experimental types: If there were no standard types in MIME::Types + # list, try to return the first experimental one + # + # 5. Raw `file` command: Just use the output of the `file` command raw, or + # a sensible default. This is cached from Step 2. + + EMPTY_TYPE = "inode/x-empty" + SENSIBLE_DEFAULT = "application/octet-stream" + + def initialize(filepath) + @filepath = filepath + end + + # Returns a String describing the file's content type + def detect + if blank_name? + SENSIBLE_DEFAULT + elsif empty_file? + EMPTY_TYPE + elsif calculated_type_matches.any? + calculated_type_matches.first + else + type_from_file_contents || SENSIBLE_DEFAULT + end.to_s + end + + private + + def blank_name? + @filepath.nil? || @filepath.empty? + end + + def empty_file? + File.exist?(@filepath) && File.size(@filepath) == 0 + end + + alias :empty? :empty_file? + + def calculated_type_matches + possible_types.select do |content_type| + content_type == type_from_file_contents + end + end + + def possible_types + MIME::Types.type_for(@filepath).collect(&:content_type) + end + + def type_from_file_contents + type_from_mime_magic || type_from_file_command + rescue Errno::ENOENT => e + Paperclip.log("Error while determining content type: #{e}") + SENSIBLE_DEFAULT + end + + def type_from_mime_magic + @type_from_mime_magic ||= File.open(@filepath) do |file| + MimeMagic.by_magic(file).try(:type) + end + end + + def type_from_file_command + @type_from_file_command ||= + FileCommandContentTypeDetector.new(@filepath).detect + end + end +end diff --git a/lib/paperclip/errors.rb b/lib/paperclip/errors.rb new file mode 100644 index 000000000..aa9591982 --- /dev/null +++ b/lib/paperclip/errors.rb @@ -0,0 +1,34 @@ +module Paperclip + # A base error class for Paperclip. Most of the error that will be thrown + # from Paperclip will inherits from this class. + class Error < StandardError + end + + module Errors + # Will be thrown when a storage method is not found. + class StorageMethodNotFound < Paperclip::Error + end + + # Will be thrown when a command or executable is not found. + class CommandNotFoundError < Paperclip::Error + end + + # Attachments require a content_type or file_name validator, + # or to have explicitly opted out of them. + class MissingRequiredValidatorError < Paperclip::Error + end + + # Will be thrown when ImageMagic cannot determine the uploaded file's + # metadata, usually this would mean the file is not an image. If you are + # consistently receiving this error on PDFs make sure that you have + # installed Ghostscript. + class NotIdentifiedByImageMagickError < Paperclip::Error + end + + # Will be thrown if the interpolation is creating an infinite loop. If you + # are creating an interpolator which might cause an infinite loop, you + # should be throwing this error upon the infinite loop as well. + class InfiniteInterpolationError < Paperclip::Error + end + end +end diff --git a/lib/paperclip/file_command_content_type_detector.rb b/lib/paperclip/file_command_content_type_detector.rb new file mode 100644 index 000000000..b8258c66d --- /dev/null +++ b/lib/paperclip/file_command_content_type_detector.rb @@ -0,0 +1,30 @@ +module Paperclip + class FileCommandContentTypeDetector + SENSIBLE_DEFAULT = "application/octet-stream" + + def initialize(filename) + @filename = filename + end + + def detect + type_from_file_command + end + + private + + def type_from_file_command + # On BSDs, `file` doesn't give a result code of 1 if the file doesn't exist. + type = begin + Paperclip.run("file", "-b --mime :file", file: @filename) + rescue Terrapin::CommandLineError => e + Paperclip.log("Error while determining content type: #{e}") + SENSIBLE_DEFAULT + end + + if type.nil? || type.match(/\(.*?\)/) + type = SENSIBLE_DEFAULT + end + type.split(/[:;\s]+/)[0] + end + end +end diff --git a/lib/paperclip/filename_cleaner.rb b/lib/paperclip/filename_cleaner.rb new file mode 100644 index 000000000..9e28699ea --- /dev/null +++ b/lib/paperclip/filename_cleaner.rb @@ -0,0 +1,15 @@ +module Paperclip + class FilenameCleaner + def initialize(invalid_character_regex) + @invalid_character_regex = invalid_character_regex + end + + def call(filename) + if @invalid_character_regex + filename.gsub(@invalid_character_regex, "_") + else + filename + end + end + end +end diff --git a/lib/paperclip/geometry.rb b/lib/paperclip/geometry.rb index 4ef716262..d27802493 100644 --- a/lib/paperclip/geometry.rb +++ b/lib/paperclip/geometry.rb @@ -4,35 +4,40 @@ module Paperclip class Geometry attr_accessor :height, :width, :modifier + EXIF_ROTATED_ORIENTATION_VALUES = [5, 6, 7, 8] + # Gives a Geometry representing the given height and width - def initialize width = nil, height = nil, modifier = nil - @height = height.to_f - @width = width.to_f - @modifier = modifier - end - - # Uses ImageMagick to determing the dimensions of a file, passed in as either a - # File or path. - # NOTE: (race cond) Do not reassign the 'file' variable inside this method as it is likely to be - # a Tempfile object, which would be eligible for file deletion when no longer referenced. - def self.from_file file - file_path = file.respond_to?(:path) ? file.path : file - raise(Paperclip::NotIdentifiedByImageMagickError.new("Cannot find the geometry of a file with a blank name")) if file_path.blank? - geometry = begin - Paperclip.run("identify", "-format %wx%h :file", :file => "#{file_path}[0]") - rescue Cocaine::ExitStatusError - "" - rescue Cocaine::CommandNotFoundError => e - raise Paperclip::CommandNotFoundError.new("Could not run the `identify` command. Please install ImageMagick.") - end - parse(geometry) || - raise(NotIdentifiedByImageMagickError.new("#{file_path} is not recognized by the 'identify' command.")) - end - - # Parses a "WxH" formatted string, where W is the width and H is the height. - def self.parse string - if match = (string && string.match(/\b(\d*)x?(\d*)\b([\>\<\#\@\%^!])?/i)) - Geometry.new(*match[1,3]) + def initialize(width = nil, height = nil, modifier = nil) + if width.is_a?(Hash) + options = width + @height = options[:height].to_f + @width = options[:width].to_f + @modifier = options[:modifier] + @orientation = options[:orientation].to_i + else + @height = height.to_f + @width = width.to_f + @modifier = modifier + end + end + + # Extracts the Geometry from a file (or path to a file) + def self.from_file(file) + GeometryDetector.new(file).make + end + + # Extracts the Geometry from a "WxH,O" string + # Where W is the width, H is the height, + # and O is the EXIF orientation + def self.parse(string) + GeometryParser.new(string).make + end + + # Swaps the height and width if necessary + def auto_orient + if EXIF_ROTATED_ORIENTATION_VALUES.include?(@orientation) + @height, @width = @width, @height + @orientation -= 4 end end @@ -99,6 +104,33 @@ def transformation_to dst, crop = false [ scale_geometry, crop_geometry ] end + # resize to a new geometry + # @param geometry [String] the Paperclip geometry definition to resize to + # @example + # Paperclip::Geometry.new(150, 150).resize_to('50x50!') + # #=> Paperclip::Geometry(50, 50) + def resize_to(geometry) + new_geometry = Paperclip::Geometry.parse geometry + case new_geometry.modifier + when '!', '#' + new_geometry + when '>' + if new_geometry.width >= self.width && new_geometry.height >= self.height + self + else + scale_to new_geometry + end + when '<' + if new_geometry.width <= self.width || new_geometry.height <= self.height + self + else + scale_to new_geometry + end + else + scale_to new_geometry + end + end + private def scaling dst, ratio @@ -116,5 +148,11 @@ def cropping dst, ratio, scale "%dx%d+%d+%d" % [ dst.width, dst.height, (self.width * scale - dst.width) / 2, 0 ] end end + + # scale to the requested geometry and preserve the aspect ratio + def scale_to(new_geometry) + scale = [new_geometry.width.to_f / self.width.to_f , new_geometry.height.to_f / self.height.to_f].min + Paperclip::Geometry.new((self.width * scale).round, (self.height * scale).round) + end end end diff --git a/lib/paperclip/geometry_detector_factory.rb b/lib/paperclip/geometry_detector_factory.rb new file mode 100644 index 000000000..9768e5a82 --- /dev/null +++ b/lib/paperclip/geometry_detector_factory.rb @@ -0,0 +1,48 @@ +module Paperclip + class GeometryDetector + def initialize(file) + @file = file + raise_if_blank_file + end + + def make + geometry = GeometryParser.new(geometry_string.strip).make + geometry || raise(Errors::NotIdentifiedByImageMagickError.new) + end + + private + + def geometry_string + begin + orientation = Paperclip.options[:use_exif_orientation] ? + "%[exif:orientation]" : "1" + Paperclip.run( + Paperclip.options[:is_windows] ? "magick identify" : "identify", + "-format '%wx%h,#{orientation}' :file", { + :file => "#{path}[0]" + }, { + :swallow_stderr => true + } + ) + rescue Terrapin::ExitStatusError + "" + rescue Terrapin::CommandNotFoundError => e + raise_because_imagemagick_missing + end + end + + def path + @file.respond_to?(:path) ? @file.path : @file + end + + def raise_if_blank_file + if path.blank? + raise Errors::NotIdentifiedByImageMagickError.new("Cannot find the geometry of a file with a blank name") + end + end + + def raise_because_imagemagick_missing + raise Errors::CommandNotFoundError.new("Could not run the `identify` command. Please install ImageMagick.") + end + end +end diff --git a/lib/paperclip/geometry_parser_factory.rb b/lib/paperclip/geometry_parser_factory.rb new file mode 100644 index 000000000..611332a52 --- /dev/null +++ b/lib/paperclip/geometry_parser_factory.rb @@ -0,0 +1,31 @@ +module Paperclip + class GeometryParser + FORMAT = /\b(\d*)x?(\d*)\b(?:,(\d?))?(\@\>|\>\@|[\>\<\#\@\%^!])?/i + def initialize(string) + @string = string + end + + def make + if match + Geometry.new( + :height => @height, + :width => @width, + :modifier => @modifier, + :orientation => @orientation + ) + end + end + + private + + def match + if actual_match = @string && @string.match(FORMAT) + @width = actual_match[1] + @height = actual_match[2] + @orientation = actual_match[3] + @modifier = actual_match[4] + end + actual_match + end + end +end diff --git a/lib/paperclip/glue.rb b/lib/paperclip/glue.rb new file mode 100644 index 000000000..c8d9e89a3 --- /dev/null +++ b/lib/paperclip/glue.rb @@ -0,0 +1,17 @@ +require 'paperclip/callbacks' +require 'paperclip/validators' +require 'paperclip/schema' + +module Paperclip + module Glue + def self.included(base) + base.extend ClassMethods + base.send :include, Callbacks + base.send :include, Validators + base.send :include, Schema if defined? ActiveRecord::Base + + locale_path = Dir.glob(File.dirname(__FILE__) + "/locales/*.{rb,yml}") + I18n.load_path += locale_path unless I18n.load_path.include?(locale_path) + end + end +end diff --git a/lib/paperclip/has_attached_file.rb b/lib/paperclip/has_attached_file.rb new file mode 100644 index 000000000..ef0459556 --- /dev/null +++ b/lib/paperclip/has_attached_file.rb @@ -0,0 +1,115 @@ +module Paperclip + class HasAttachedFile + def self.define_on(klass, name, options) + new(klass, name, options).define + end + + def initialize(klass, name, options) + @klass = klass + @name = name + @options = options + end + + def define + define_flush_errors + define_getters + define_setter + define_query + register_new_attachment + add_active_record_callbacks + add_paperclip_callbacks + add_required_validations + end + + private + + def define_flush_errors + @klass.send(:validates_each, @name) do |record, attr, value| + attachment = record.send(@name) + attachment.send(:flush_errors) + end + end + + def define_getters + define_instance_getter + define_class_getter + end + + def define_instance_getter + name = @name + options = @options + + @klass.send :define_method, @name do |*args| + ivar = "@attachment_#{name}" + attachment = instance_variable_get(ivar) + + if attachment.nil? + attachment = Attachment.new(name, self, options) + instance_variable_set(ivar, attachment) + end + + if args.length > 0 + attachment.to_s(args.first) + else + attachment + end + end + end + + def define_class_getter + @klass.extend(ClassMethods) + end + + def define_setter + name = @name + @klass.send :define_method, "#{@name}=" do |file| + send(name).assign(file) + end + end + + def define_query + name = @name + @klass.send :define_method, "#{@name}?" do + send(name).file? + end + end + + def register_new_attachment + Paperclip::AttachmentRegistry.register(@klass, @name, @options) + end + + def add_required_validations + options = Paperclip::Attachment.default_options.deep_merge(@options) + if options[:validate_media_type] != false + name = @name + @klass.validates_media_type_spoof_detection name, + :if => ->(instance){ instance.send(name).dirty? } + end + end + + def add_active_record_callbacks + name = @name + @klass.send(:after_save) { send(name).send(:save) } + @klass.send(:before_destroy) { send(name).send(:queue_all_for_delete) } + if @klass.respond_to?(:after_commit) + @klass.send(:after_commit, on: :destroy) do + send(name).send(:flush_deletes) + end + else + @klass.send(:after_destroy) { send(name).send(:flush_deletes) } + end + end + + def add_paperclip_callbacks + @klass.send( + :define_paperclip_callbacks, + :post_process, :"#{@name}_post_process") + end + + module ClassMethods + def attachment_definitions + Paperclip::AttachmentRegistry.definitions_for(self) + end + end + end +end diff --git a/lib/paperclip/helpers.rb b/lib/paperclip/helpers.rb new file mode 100644 index 000000000..480f78a94 --- /dev/null +++ b/lib/paperclip/helpers.rb @@ -0,0 +1,60 @@ +module Paperclip + module Helpers + def configure + yield(self) if block_given? + end + + def interpolates key, &block + Paperclip::Interpolations[key] = block + end + + # The run method takes the name of a binary to run, the arguments + # to that binary, the values to interpolate and some local options. + # + # :cmd -> The name of a binary to run. + # + # :arguments -> The command line arguments to that binary. + # + # :interpolation_values -> Values to be interpolated into the arguments. + # + # :local_options -> The options to be used by Cocain::CommandLine. + # These could be: runner + # logger + # swallow_stderr + # expected_outcodes + # environment + # runner_options + # + def run(cmd, arguments = "", interpolation_values = {}, local_options = {}) + command_path = options[:command_path] + terrapin_path_array = Terrapin::CommandLine.path.try(:split, Terrapin::OS.path_separator) + Terrapin::CommandLine.path = [terrapin_path_array, command_path].flatten.compact.uniq + if logging? && (options[:log_command] || local_options[:log_command]) + local_options = local_options.merge(:logger => logger) + end + Terrapin::CommandLine.new(cmd, arguments, local_options).run(interpolation_values) + end + + # Find all instances of the given Active Record model +klass+ with attachment +name+. + # This method is used by the refresh rake tasks. + def each_instance_with_attachment(klass, name) + class_for(klass).unscoped.where("#{name}_file_name IS NOT NULL").find_each do |instance| + yield(instance) + end + end + + def class_for(class_name) + class_name.split('::').inject(Object) do |klass, partial_class_name| + if klass.const_defined?(partial_class_name) + klass.const_get(partial_class_name, false) + else + klass.const_missing(partial_class_name) + end + end + end + + def reset_duplicate_clash_check! + @names_url = nil + end + end +end diff --git a/lib/paperclip/interpolations.rb b/lib/paperclip/interpolations.rb index 9d44b1cf3..26fd0c68c 100644 --- a/lib/paperclip/interpolations.rb +++ b/lib/paperclip/interpolations.rb @@ -5,11 +5,13 @@ module Paperclip # Paperclip.interpolates method. module Interpolations extend self + ID_PARTITION_LIMIT = 1_000_000_000 # Hash assignment of interpolations. Included only for compatibility, # and is not intended for normal use. def self.[]= name, block define_method(name, &block) + @interpolators_cache = nil end # Hash access of interpolations. Included only for compatibility, @@ -20,7 +22,7 @@ def self.[] name # Returns a sorted list of all interpolations. def self.all - self.instance_methods(false).sort + self.instance_methods(false).sort! end # Perform the actual interpolation. Takes the pattern to interpolate @@ -29,24 +31,32 @@ def self.all # an interpolation pattern for Paperclip to use. def self.interpolate pattern, *args pattern = args.first.instance.send(pattern) if pattern.kind_of? Symbol - all.reverse.inject(pattern) do |result, tag| - result.gsub(/:#{tag}/) do |match| - send( tag, *args ) - end + result = pattern.dup + interpolators_cache.each do |method, token| + result.gsub!(token) { send(method, *args) } if result.include?(token) end + result + end + + def self.interpolators_cache + @interpolators_cache ||= all.reverse!.map! { |method| [method, ":#{method}"] } + end + + def self.plural_cache + @plural_cache ||= PluralCache.new end # Returns the filename, the same way as ":basename.:extension" would. def filename attachment, style_name - [ basename(attachment, style_name), extension(attachment, style_name) ].reject(&:blank?).join(".") + [ basename(attachment, style_name), extension(attachment, style_name) ].delete_if(&:empty?).join(".".freeze) end # Returns the interpolated URL. Will raise an error if the url itself # contains ":url" to prevent infinite recursion. This interpolation # is used in the default :path to ease default specifications. - RIGHT_HERE = "#{__FILE__.gsub(%r{^\./}, "")}:#{__LINE__ + 3}" + RIGHT_HERE = "#{__FILE__.gsub(%r{\A\./}, "")}:#{__LINE__ + 3}" def url attachment, style_name - raise InfiniteInterpolationError if caller.any?{|b| b.index(RIGHT_HERE) } + raise Errors::InfiniteInterpolationError if caller.any?{|b| b.index(RIGHT_HERE) } attachment.url(style_name, :timestamp => false, :escape => false) end @@ -81,25 +91,36 @@ def rails_env attachment, style_name # all class names. Calling #class will return the expected class. def class attachment = nil, style_name = nil return super() if attachment.nil? && style_name.nil? - attachment.instance.class.to_s.underscore.pluralize + plural_cache.underscore_and_pluralize_class(attachment.instance.class) end # Returns the basename of the file. e.g. "file" for "file.jpg" def basename attachment, style_name - attachment.original_filename.gsub(/#{Regexp.escape(File.extname(attachment.original_filename))}$/, "") + File.basename(attachment.original_filename, ".*".freeze) end # Returns the extension of the file. e.g. "jpg" for "file.jpg" # If the style has a format defined, it will return the format instead # of the actual extension. def extension attachment, style_name - ((style = attachment.styles[style_name.to_sym]) && style[:format]) || - File.extname(attachment.original_filename).gsub(/^\.+/, "") + ((style = attachment.styles[style_name.to_s.to_sym]) && style[:format]) || + File.extname(attachment.original_filename).sub(/\A\.+/, "".freeze) end - # Returns an extension based on the content type. e.g. "jpeg" for "image/jpeg". + # Returns the dot+extension of the file. e.g. ".jpg" for "file.jpg" + # If the style has a format defined, it will return the format instead + # of the actual extension. If the extension is empty, no dot is added. + def dotextension attachment, style_name + ext = extension(attachment, style_name) + ext.empty? ? ext : ".#{ext}" + end + + # Returns an extension based on the content type. e.g. "jpeg" for + # "image/jpeg". If the style has a specified format, it will override the + # content-type detection. + # # Each mime type generally has multiple extensions associated with it, so - # if the extension from teh original filename is one of these extensions, + # if the extension from the original filename is one of these extensions, # that extension is used, otherwise, the first in the list is used. def content_type_extension attachment, style_name mime_type = MIME::Types[attachment.content_type] @@ -110,7 +131,10 @@ def content_type_extension attachment, style_name end original_extension = extension(attachment, style_name) - if extensions_for_mime_type.include? original_extension + style = attachment.styles[style_name.to_s.to_sym] + if style && style[:format] + style[:format].to_s + elsif extensions_for_mime_type.include? original_extension original_extension elsif !extensions_for_mime_type.empty? extensions_for_mime_type.first @@ -118,7 +142,7 @@ def content_type_extension attachment, style_name # It's possible, though unlikely, that the mime type is not in the # database, so just use the part after the '/' in the mime type as the # extension. - %r{/([^/]*)$}.match(attachment.content_type)[1] + %r{/([^/]*)\z}.match(attachment.content_type)[1] end end @@ -152,9 +176,13 @@ def hash attachment=nil, style_name=nil def id_partition attachment, style_name case id = attachment.instance.id when Integer - ("%09d" % id).scan(/\d{3}/).join("/") + if id < ID_PARTITION_LIMIT + ("%09d".freeze % id).scan(/\d{3}/).join("/".freeze) + else + ("%012d".freeze % id).scan(/\d{3}/).join("/".freeze) + end when String - id.scan(/.{3}/).first(3).join("/") + id.scan(/.{3}/).first(3).join("/".freeze) else nil end @@ -163,7 +191,7 @@ def id_partition attachment, style_name # Returns the pluralized form of the attachment name. e.g. # "avatars" for an attachment of :avatar def attachment attachment, style_name - attachment.name.to_s.downcase.pluralize + plural_cache.pluralize_symbol(attachment.name) end # Returns the style, or the default style if nil is supplied. diff --git a/lib/paperclip/interpolations/plural_cache.rb b/lib/paperclip/interpolations/plural_cache.rb new file mode 100644 index 000000000..23bd39fe0 --- /dev/null +++ b/lib/paperclip/interpolations/plural_cache.rb @@ -0,0 +1,18 @@ +module Paperclip + module Interpolations + class PluralCache + def initialize + @symbol_cache = {}.compare_by_identity + @klass_cache = {}.compare_by_identity + end + + def pluralize_symbol(symbol) + @symbol_cache[symbol] ||= symbol.to_s.downcase.pluralize + end + + def underscore_and_pluralize_class(klass) + @klass_cache[klass] ||= klass.name.underscore.pluralize + end + end + end +end diff --git a/lib/paperclip/io_adapters/abstract_adapter.rb b/lib/paperclip/io_adapters/abstract_adapter.rb new file mode 100644 index 000000000..22717616d --- /dev/null +++ b/lib/paperclip/io_adapters/abstract_adapter.rb @@ -0,0 +1,74 @@ +require 'active_support/core_ext/module/delegation' + +module Paperclip + class AbstractAdapter + OS_RESTRICTED_CHARACTERS = %r{[/:]} + + attr_reader :content_type, :original_filename, :size + delegate :binmode, :binmode?, :close, :close!, :closed?, :eof?, :path, :readbyte, :rewind, :unlink, :to => :@tempfile + alias :length :size + + def initialize(target, options = {}) + @target = target + @options = options + end + + def fingerprint + @fingerprint ||= begin + digest = @options.fetch(:hash_digest).new + File.open(path, "rb") do |f| + buf = "" + digest.update(buf) while f.read(16384, buf) + end + digest.hexdigest + end + end + + def read(length = nil, buffer = nil) + @tempfile.read(length, buffer) + end + + def inspect + "#{self.class}: #{self.original_filename}" + end + + def original_filename=(new_filename) + return unless new_filename + @original_filename = new_filename.gsub(OS_RESTRICTED_CHARACTERS, "_") + end + + def nil? + false + end + + def assignment? + true + end + + private + + def destination + @destination ||= TempfileFactory.new.generate(@original_filename.to_s) + end + + def copy_to_tempfile(src) + link_or_copy_file(src.path, destination.path) + destination + end + + def link_or_copy_file(src, dest) + begin + Paperclip.log("Trying to link #{src} to #{dest}") + FileUtils.ln(src, dest, force: true) # overwrite existing + rescue Errno::EXDEV, Errno::EPERM, Errno::ENOENT, Errno::EEXIST => e + Paperclip.log( + "Link failed with #{e.message}; copying link #{src} to #{dest}" + ) + FileUtils.cp(src, dest) + end + + @destination.close + @destination.open.binmode + end + end +end diff --git a/lib/paperclip/io_adapters/attachment_adapter.rb b/lib/paperclip/io_adapters/attachment_adapter.rb new file mode 100644 index 000000000..f44e8423a --- /dev/null +++ b/lib/paperclip/io_adapters/attachment_adapter.rb @@ -0,0 +1,41 @@ +module Paperclip + class AttachmentAdapter < AbstractAdapter + def self.register + Paperclip.io_adapters.register self do |target| + Paperclip::Attachment === target || Paperclip::Style === target + end + end + + def initialize(target, options = {}) + super + @target, @style = case target + when Paperclip::Attachment + [target, :original] + when Paperclip::Style + [target.attachment, target.name] + end + + cache_current_values + end + + private + + def cache_current_values + self.original_filename = @target.original_filename + @content_type = @target.content_type + @tempfile = copy_to_tempfile(@target) + @size = @tempfile.size || @target.size + end + + def copy_to_tempfile(source) + if source.staged? + link_or_copy_file(source.staged_path(@style), destination.path) + else + source.copy_to_local_file(@style, destination.path) + end + destination + end + end +end + +Paperclip::AttachmentAdapter.register diff --git a/lib/paperclip/io_adapters/data_uri_adapter.rb b/lib/paperclip/io_adapters/data_uri_adapter.rb new file mode 100644 index 000000000..5bc31f308 --- /dev/null +++ b/lib/paperclip/io_adapters/data_uri_adapter.rb @@ -0,0 +1,22 @@ +module Paperclip + class DataUriAdapter < StringioAdapter + def self.register + Paperclip.io_adapters.register self do |target| + String === target && target =~ REGEXP + end + end + + REGEXP = /\Adata:([-\w]+\/[-\w\+\.]+)?;base64,(.*)/m + + def initialize(target_uri, options = {}) + super(extract_target(target_uri), options) + end + + private + + def extract_target(uri) + data_uri_parts = uri.match(REGEXP) || [] + StringIO.new(Base64.decode64(data_uri_parts[2] || "")) + end + end +end diff --git a/lib/paperclip/io_adapters/empty_string_adapter.rb b/lib/paperclip/io_adapters/empty_string_adapter.rb new file mode 100644 index 000000000..073dc8da9 --- /dev/null +++ b/lib/paperclip/io_adapters/empty_string_adapter.rb @@ -0,0 +1,19 @@ +module Paperclip + class EmptyStringAdapter < AbstractAdapter + def self.register + Paperclip.io_adapters.register self do |target| + target.is_a?(String) && target.empty? + end + end + + def nil? + false + end + + def assignment? + false + end + end +end + +Paperclip::EmptyStringAdapter.register diff --git a/lib/paperclip/io_adapters/file_adapter.rb b/lib/paperclip/io_adapters/file_adapter.rb new file mode 100644 index 000000000..cc127bb0a --- /dev/null +++ b/lib/paperclip/io_adapters/file_adapter.rb @@ -0,0 +1,28 @@ +module Paperclip + class FileAdapter < AbstractAdapter + def self.register + Paperclip.io_adapters.register self do |target| + File === target || ::Tempfile === target + end + end + + def initialize(target, options = {}) + super + cache_current_values + end + + private + + def cache_current_values + if @target.respond_to?(:original_filename) + self.original_filename = @target.original_filename + end + self.original_filename ||= File.basename(@target.path) + @tempfile = copy_to_tempfile(@target) + @content_type = ContentTypeDetector.new(@target.path).detect + @size = File.size(@target) + end + end +end + +Paperclip::FileAdapter.register diff --git a/lib/paperclip/io_adapters/http_url_proxy_adapter.rb b/lib/paperclip/io_adapters/http_url_proxy_adapter.rb new file mode 100644 index 000000000..4b261bfc3 --- /dev/null +++ b/lib/paperclip/io_adapters/http_url_proxy_adapter.rb @@ -0,0 +1,16 @@ +module Paperclip + class HttpUrlProxyAdapter < UriAdapter + def self.register + Paperclip.io_adapters.register self do |target| + String === target && target =~ REGEXP + end + end + + REGEXP = /\Ahttps?:\/\// + + def initialize(target, options = {}) + escaped = URI.escape(target) + super(URI(target == URI.unescape(target) ? escaped : target), options) + end + end +end diff --git a/lib/paperclip/io_adapters/identity_adapter.rb b/lib/paperclip/io_adapters/identity_adapter.rb new file mode 100644 index 000000000..6ec4237fa --- /dev/null +++ b/lib/paperclip/io_adapters/identity_adapter.rb @@ -0,0 +1,18 @@ +module Paperclip + class IdentityAdapter < AbstractAdapter + def self.register + Paperclip.io_adapters.register Paperclip::IdentityAdapter.new do |target| + Paperclip.io_adapters.registered?(target) + end + end + + def initialize + end + + def new(target, _) + target + end + end +end + +Paperclip::IdentityAdapter.register diff --git a/lib/paperclip/io_adapters/nil_adapter.rb b/lib/paperclip/io_adapters/nil_adapter.rb new file mode 100644 index 000000000..20c73f6a4 --- /dev/null +++ b/lib/paperclip/io_adapters/nil_adapter.rb @@ -0,0 +1,37 @@ +module Paperclip + class NilAdapter < AbstractAdapter + def self.register + Paperclip.io_adapters.register self do |target| + target.nil? || ((Paperclip::Attachment === target) && !target.present?) + end + end + + def initialize(_target, _options = {}); end + + def original_filename + "" + end + + def content_type + "" + end + + def size + 0 + end + + def nil? + true + end + + def read(*_args) + nil + end + + def eof? + true + end + end +end + +Paperclip::NilAdapter.register diff --git a/lib/paperclip/io_adapters/registry.rb b/lib/paperclip/io_adapters/registry.rb new file mode 100644 index 000000000..7eb295868 --- /dev/null +++ b/lib/paperclip/io_adapters/registry.rb @@ -0,0 +1,36 @@ +module Paperclip + class AdapterRegistry + class NoHandlerError < Paperclip::Error; end + + attr_reader :registered_handlers + + def initialize + @registered_handlers = [] + end + + def register(handler_class, &block) + @registered_handlers << [block, handler_class] + end + + def unregister(handler_class) + @registered_handlers.reject! { |_, klass| klass == handler_class } + end + + def handler_for(target) + @registered_handlers.each do |tester, handler| + return handler if tester.call(target) + end + raise NoHandlerError.new("No handler found for #{target.inspect}") + end + + def registered?(target) + @registered_handlers.any? do |tester, handler| + handler === target + end + end + + def for(target, options = {}) + handler_for(target).new(target, options) + end + end +end diff --git a/lib/paperclip/io_adapters/stringio_adapter.rb b/lib/paperclip/io_adapters/stringio_adapter.rb new file mode 100644 index 000000000..03da9c407 --- /dev/null +++ b/lib/paperclip/io_adapters/stringio_adapter.rb @@ -0,0 +1,36 @@ +module Paperclip + class StringioAdapter < AbstractAdapter + def self.register + Paperclip.io_adapters.register self do |target| + StringIO === target + end + end + + def initialize(target, options = {}) + super + cache_current_values + end + + attr_writer :content_type + + private + + def cache_current_values + self.original_filename = @target.original_filename if @target.respond_to?(:original_filename) + self.original_filename ||= "data" + @tempfile = copy_to_tempfile(@target) + @content_type = ContentTypeDetector.new(@tempfile.path).detect + @size = @target.size + end + + def copy_to_tempfile(source) + while data = source.read(16*1024) + destination.write(data) + end + destination.rewind + destination + end + end +end + +Paperclip::StringioAdapter.register diff --git a/lib/paperclip/io_adapters/uploaded_file_adapter.rb b/lib/paperclip/io_adapters/uploaded_file_adapter.rb new file mode 100644 index 000000000..b1dee925b --- /dev/null +++ b/lib/paperclip/io_adapters/uploaded_file_adapter.rb @@ -0,0 +1,46 @@ +module Paperclip + class UploadedFileAdapter < AbstractAdapter + def self.register + Paperclip.io_adapters.register self do |target| + target.class.name.include?("UploadedFile") + end + end + + def initialize(target, options = {}) + super + cache_current_values + + if @target.respond_to?(:tempfile) + @tempfile = copy_to_tempfile(@target.tempfile) + else + @tempfile = copy_to_tempfile(@target) + end + end + + class << self + attr_accessor :content_type_detector + end + + private + + def cache_current_values + self.original_filename = @target.original_filename + @content_type = determine_content_type + @size = File.size(@target.path) + end + + def content_type_detector + self.class.content_type_detector || Paperclip::ContentTypeDetector + end + + def determine_content_type + content_type = @target.content_type.to_s.strip + if content_type_detector + content_type = content_type_detector.new(@target.path).detect + end + content_type + end + end +end + +Paperclip::UploadedFileAdapter.register diff --git a/lib/paperclip/io_adapters/uri_adapter.rb b/lib/paperclip/io_adapters/uri_adapter.rb new file mode 100644 index 000000000..26ea7edad --- /dev/null +++ b/lib/paperclip/io_adapters/uri_adapter.rb @@ -0,0 +1,68 @@ +require "open-uri" + +module Paperclip + class UriAdapter < AbstractAdapter + attr_writer :content_type + + def self.register + Paperclip.io_adapters.register self do |target| + target.is_a?(URI) + end + end + + def initialize(target, options = {}) + super + @content = download_content + cache_current_values + @tempfile = copy_to_tempfile(@content) + end + + private + + def cache_current_values + self.content_type = content_type_from_content || "text/html" + + self.original_filename = filename_from_content_disposition || + filename_from_path || default_filename + @size = @content.size + end + + def content_type_from_content + @content.meta["content-type"].presence + end + + def filename_from_content_disposition + if @content.meta.key?("content-disposition") && @content.meta["content-disposition"].match(/filename/i) + # can include both filename and filename* values according to RCF6266. filename should come first + _, filename = @content.meta["content-disposition"].split(/filename\*?\s*=\s*/i) + + # filename can be enclosed in quotes or not + matches = filename.match(/"(.*)"/) + matches ? matches[1] : filename.split(';')[0] + end + end + + def filename_from_path + @target.path.split("/").last + end + + def default_filename + "index.html" + end + + def download_content + options = { read_timeout: Paperclip.options[:read_timeout] }.compact + + open(@target, **options) + end + + def copy_to_tempfile(src) + while data = src.read(16 * 1024) + destination.write(data) + end + src.close + destination.rewind + destination + end + end +end diff --git a/lib/paperclip/iostream.rb b/lib/paperclip/iostream.rb deleted file mode 100644 index d6d00ce55..000000000 --- a/lib/paperclip/iostream.rb +++ /dev/null @@ -1,45 +0,0 @@ -# Provides method that can be included on File-type objects (IO, StringIO, Tempfile, etc) to allow stream copying -# and Tempfile conversion. -module IOStream - # Returns a Tempfile containing the contents of the readable object. - def to_tempfile(object) - return object.to_tempfile if object.respond_to?(:to_tempfile) - name = object.respond_to?(:original_filename) ? object.original_filename : (object.respond_to?(:path) ? object.path : "stream") - tempfile = Paperclip::Tempfile.new(["stream", File.extname(name)]) - tempfile.binmode - stream_to(object, tempfile) - end - - # Copies one read-able object from one place to another in blocks, obviating the need to load - # the whole thing into memory. Defaults to 8k blocks. Returns a File if a String is passed - # in as the destination and returns the IO or Tempfile as passed in if one is sent as the destination. - def stream_to object, path_or_file, in_blocks_of = 8192 - dstio = case path_or_file - when String then File.new(path_or_file, "wb+") - when IO then path_or_file - when Tempfile then path_or_file - end - buffer = "" - object.rewind - while object.read(in_blocks_of, buffer) do - dstio.write(buffer) - end - dstio.rewind - dstio - end -end - -# Corrects a bug in Windows when asking for Tempfile size. -if defined?(Tempfile) && RUBY_PLATFORM !~ /java/ - class Tempfile - def size - if @tmpfile - @tmpfile.fsync - @tmpfile.flush - @tmpfile.stat.size - else - 0 - end - end - end -end diff --git a/lib/paperclip/locales/en.yml b/lib/paperclip/locales/en.yml new file mode 100644 index 000000000..2709a023b --- /dev/null +++ b/lib/paperclip/locales/en.yml @@ -0,0 +1,18 @@ +en: + errors: + messages: + in_between: "must be in between %{min} and %{max}" + spoofed_media_type: "has contents that are not what they are reported to be" + + number: + human: + storage_units: + format: "%n %u" + units: + byte: + one: "Byte" + other: "Bytes" + kb: "KB" + mb: "MB" + gb: "GB" + tb: "TB" diff --git a/lib/paperclip/logger.rb b/lib/paperclip/logger.rb new file mode 100644 index 000000000..761ea56a9 --- /dev/null +++ b/lib/paperclip/logger.rb @@ -0,0 +1,21 @@ +module Paperclip + module Logger + # Log a paperclip-specific line. This will log to STDOUT + # by default. Set Paperclip.options[:log] to false to turn off. + def log(message) + logger.info("[paperclip] #{message}") if logging? + end + + def logger #:nodoc: + @logger ||= options[:logger] || ::Logger.new(STDOUT) + end + + def logger=(logger) + @logger = logger + end + + def logging? #:nodoc: + options[:log] + end + end +end diff --git a/lib/paperclip/matchers.rb b/lib/paperclip/matchers.rb index 4dbfdc387..6f6c8ceee 100644 --- a/lib/paperclip/matchers.rb +++ b/lib/paperclip/matchers.rb @@ -15,7 +15,7 @@ module Shoulda # # And _include_ the module: # - # Spec::Runner.configure do |config| + # RSpec.configure do |config| # config.include Paperclip::Shoulda::Matchers # end # @@ -41,7 +41,7 @@ module Shoulda # # class ActiveSupport::TestCase # extend Paperclip::Shoulda::Matchers - # + # # #...other initializers...# # end # @@ -57,7 +57,7 @@ module Shoulda # should validate_attachment_size(:avatar). # less_than(2.megabytes) # end - # + # module Matchers end end diff --git a/lib/paperclip/matchers/have_attached_file_matcher.rb b/lib/paperclip/matchers/have_attached_file_matcher.rb index d3c1957ff..42b963012 100644 --- a/lib/paperclip/matchers/have_attached_file_matcher.rb +++ b/lib/paperclip/matchers/have_attached_file_matcher.rb @@ -20,16 +20,17 @@ def initialize attachment_name def matches? subject @subject = subject @subject = @subject.class unless Class === @subject - responds? && has_column? && included? + responds? && has_column? end def failure_message "Should have an attachment named #{@attachment_name}" end - def negative_failure_message + def failure_message_when_negated "Should not have an attachment named #{@attachment_name}" end + alias negative_failure_message failure_message_when_negated def description "have an attachment named #{@attachment_name}" @@ -47,10 +48,6 @@ def responds? def has_column? @subject.column_names.include?("#{@attachment_name}_file_name") end - - def included? - @subject.ancestors.include?(Paperclip::InstanceMethods) - end end end end diff --git a/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb b/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb index fec6f0457..0dba19396 100644 --- a/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb +++ b/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb @@ -39,18 +39,10 @@ def matches? subject end def failure_message - "".tap do |str| - str << "Content types #{@allowed_types.join(", ")} should be accepted" if @allowed_types.present? - str << "\n" if @allowed_types.present? && @rejected_types.present? - str << "Content types #{@rejected_types.join(", ")} should be rejected by #{@attachment_name}" if @rejected_types.present? - end - end - - def negative_failure_message - "".tap do |str| - str << "Content types #{@allowed_types.join(", ")} should be rejected" if @allowed_types.present? - str << "\n" if @allowed_types.present? && @rejected_types.present? - str << "Content types #{@rejected_types.join(", ")} should be accepted by #{@attachment_name}" if @rejected_types.present? + "#{expected_attachment}\n".tap do |message| + message << accepted_types_and_failures.to_s + message << "\n\n" if @allowed_types.present? && @rejected_types.present? + message << rejected_types_and_failures.to_s end end @@ -60,20 +52,47 @@ def description protected + def accepted_types_and_failures + if @allowed_types.present? + "Accept content types: #{@allowed_types.join(", ")}\n".tap do |message| + if @missing_allowed_types.present? + message << " #{@missing_allowed_types.join(", ")} were rejected." + else + message << " All were accepted successfully." + end + end + end + end + def rejected_types_and_failures + if @rejected_types.present? + "Reject content types: #{@rejected_types.join(", ")}\n".tap do |message| + if @missing_rejected_types.present? + message << " #{@missing_rejected_types.join(", ")} were accepted." + else + message << " All were rejected successfully." + end + end + end + end + + def expected_attachment + "Expected #{@attachment_name}:\n" + end + def type_allowed?(type) - file = StringIO.new(".") - file.content_type = type - @subject.attachment_for(@attachment_name).assign(file) + @subject.send("#{@attachment_name}_content_type=", type) @subject.valid? @subject.errors[:"#{@attachment_name}_content_type"].blank? end def allowed_types_allowed? - @allowed_types.all? { |type| type_allowed?(type) } + @missing_allowed_types ||= @allowed_types.reject { |type| type_allowed?(type) } + @missing_allowed_types.none? end def rejected_types_rejected? - !@rejected_types.any? { |type| type_allowed?(type) } + @missing_rejected_types ||= @rejected_types.select { |type| type_allowed?(type) } + @missing_rejected_types.none? end end end diff --git a/lib/paperclip/matchers/validate_attachment_presence_matcher.rb b/lib/paperclip/matchers/validate_attachment_presence_matcher.rb index 6f732a02d..1bf47b35f 100644 --- a/lib/paperclip/matchers/validate_attachment_presence_matcher.rb +++ b/lib/paperclip/matchers/validate_attachment_presence_matcher.rb @@ -26,9 +26,10 @@ def failure_message "Attachment #{@attachment_name} should be required" end - def negative_failure_message + def failure_message_when_negated "Attachment #{@attachment_name} should not be required" end + alias negative_failure_message failure_message_when_negated def description "require presence of attachment #{@attachment_name}" @@ -39,14 +40,18 @@ def description def error_when_not_valid? @subject.send(@attachment_name).assign(nil) @subject.valid? - not @subject.errors[:"#{@attachment_name}_file_name"].blank? + @subject.errors[:"#{@attachment_name}"].present? end def no_error_when_valid? @file = StringIO.new(".") @subject.send(@attachment_name).assign(@file) @subject.valid? - @subject.errors[:"#{@attachment_name}_file_name"].blank? + expected_message = [ + @attachment_name.to_s.titleize, + I18n.t(:blank, scope: [:errors, :messages]) + ].join(' ') + @subject.errors.full_messages.exclude?(expected_message) end end end diff --git a/lib/paperclip/matchers/validate_attachment_size_matcher.rb b/lib/paperclip/matchers/validate_attachment_size_matcher.rb index b821af44b..0030307c5 100644 --- a/lib/paperclip/matchers/validate_attachment_size_matcher.rb +++ b/lib/paperclip/matchers/validate_attachment_size_matcher.rb @@ -18,7 +18,6 @@ def validate_attachment_size name class ValidateAttachmentSizeMatcher def initialize attachment_name @attachment_name = attachment_name - @low, @high = 0, (1.0/0) end def less_than size @@ -46,9 +45,10 @@ def failure_message "Attachment #{@attachment_name} must be between #{@low} and #{@high} bytes" end - def negative_failure_message + def failure_message_when_negated "Attachment #{@attachment_name} cannot be between #{@low} and #{@high} bytes" end + alias negative_failure_message failure_message_when_negated def description "validate the size of attachment #{@attachment_name}" @@ -67,27 +67,28 @@ def passes_validation_with_size(new_size) override_method(file, :size){ new_size } override_method(file, :to_tempfile){ file } + @subject.send(@attachment_name).post_processing = false @subject.send(@attachment_name).assign(file) @subject.valid? @subject.errors[:"#{@attachment_name}_file_size"].blank? + ensure + @subject.send(@attachment_name).post_processing = true end def lower_than_low? - not passes_validation_with_size(@low - 1) + @low.nil? || !passes_validation_with_size(@low - 1) end def higher_than_low? - passes_validation_with_size(@low + 1) + @low.nil? || passes_validation_with_size(@low + 1) end def lower_than_high? - return true if @high == (1.0/0) - passes_validation_with_size(@high - 1) + @high.nil? || @high == Float::INFINITY || passes_validation_with_size(@high - 1) end def higher_than_high? - return true if @high == (1.0/0) - not passes_validation_with_size(@high + 1) + @high.nil? || @high == Float::INFINITY || !passes_validation_with_size(@high + 1) end end end diff --git a/lib/paperclip/media_type_spoof_detector.rb b/lib/paperclip/media_type_spoof_detector.rb new file mode 100644 index 000000000..09505518b --- /dev/null +++ b/lib/paperclip/media_type_spoof_detector.rb @@ -0,0 +1,93 @@ +module Paperclip + class MediaTypeSpoofDetector + def self.using(file, name, content_type) + new(file, name, content_type) + end + + def initialize(file, name, content_type) + @file = file + @name = name + @content_type = content_type || "" + end + + def spoofed? + if has_name? && media_type_mismatch? && mapping_override_mismatch? + Paperclip.log("Content Type Spoof: Filename #{File.basename(@name)} (#{supplied_content_type} from Headers, #{content_types_from_name.map(&:to_s)} from Extension), content type discovered from file command: #{calculated_content_type}. See documentation to allow this combination.") + true + else + false + end + end + + private + + def has_name? + @name.present? + end + + def has_extension? + File.extname(@name).present? + end + + def media_type_mismatch? + extension_type_mismatch? || calculated_type_mismatch? + end + + def extension_type_mismatch? + supplied_media_type.present? && + has_extension? && + !media_types_from_name.include?(supplied_media_type) + end + + def calculated_type_mismatch? + supplied_media_type.present? && + !calculated_content_type.include?(supplied_media_type) + end + + def mapping_override_mismatch? + !Array(mapped_content_type).include?(calculated_content_type) + end + + + def supplied_content_type + @content_type + end + + def supplied_media_type + @content_type.split("/").first + end + + def content_types_from_name + @content_types_from_name ||= MIME::Types.type_for(@name) + end + + def media_types_from_name + @media_types_from_name ||= content_types_from_name.collect(&:media_type) + end + + def calculated_content_type + @calculated_content_type ||= type_from_file_command.chomp + end + + def calculated_media_type + @calculated_media_type ||= calculated_content_type.split("/").first + end + + def type_from_file_command + begin + Paperclip.run("file", "-b --mime :file", file: @file.path). + split(/[:;\s]+/).first + rescue Terrapin::CommandLineError + "" + end + end + + def mapped_content_type + Paperclip.options[:content_type_mappings][filename_extension] + end + + def filename_extension + File.extname(@name.to_s.downcase).sub(/^\./, '').to_sym + end + end +end diff --git a/lib/paperclip/missing_attachment_styles.rb b/lib/paperclip/missing_attachment_styles.rb index 659a3a5d4..9d7a84acb 100644 --- a/lib/paperclip/missing_attachment_styles.rb +++ b/lib/paperclip/missing_attachment_styles.rb @@ -1,18 +1,14 @@ - +require 'paperclip/attachment_registry' require 'set' -module Paperclip +module Paperclip class << self - attr_accessor :classes_with_attachments attr_writer :registered_attachments_styles_path def registered_attachments_styles_path @registered_attachments_styles_path ||= Rails.root.join('public/system/paperclip_attachments.yml').to_s end end - self.classes_with_attachments = Set.new - - # Get list of styles saved on previous deploy (running rake paperclip:refresh:missing_styles) def self.get_registered_attachments_styles YAML.load_file(Paperclip.registered_attachments_styles_path) @@ -38,20 +34,17 @@ def self.save_current_attachments_styles! # } def self.current_attachments_styles Hash.new.tap do |current_styles| - Paperclip.classes_with_attachments.each do |klass_name| - klass = Paperclip.class_for(klass_name) - klass.attachment_definitions.each do |attachment_name, attachment_attributes| - # TODO: is it even possible to take into account Procs? - next if attachment_attributes[:styles].kind_of?(Proc) - attachment_attributes[:styles].try(:keys).try(:each) do |style_name| - klass_sym = klass.to_s.to_sym - current_styles[klass_sym] ||= Hash.new - current_styles[klass_sym][attachment_name.to_sym] ||= Array.new - current_styles[klass_sym][attachment_name.to_sym] << style_name.to_sym - current_styles[klass_sym][attachment_name.to_sym].map!(&:to_s).sort!.map!(&:to_sym).uniq! - end - end - end + Paperclip::AttachmentRegistry.each_definition do |klass, attachment_name, attachment_attributes| + # TODO: is it even possible to take into account Procs? + next if attachment_attributes[:styles].kind_of?(Proc) + attachment_attributes[:styles].try(:keys).try(:each) do |style_name| + klass_sym = klass.to_s.to_sym + current_styles[klass_sym] ||= Hash.new + current_styles[klass_sym][attachment_name.to_sym] ||= Array.new + current_styles[klass_sym][attachment_name.to_sym] << style_name.to_sym + current_styles[klass_sym][attachment_name.to_sym].map!(&:to_s).sort!.map!(&:to_sym).uniq! + end + end end end private_class_method :current_attachments_styles @@ -71,7 +64,7 @@ def self.missing_attachments_styles current_styles.each do |klass, attachment_definitions| attachment_definitions.each do |attachment_name, styles| registered = registered_styles[klass][attachment_name] || [] rescue [] - missed = styles - registered + missed = styles - registered if missed.present? klass_sym = klass.to_s.to_sym missing_styles[klass_sym] ||= Hash.new @@ -80,8 +73,7 @@ def self.missing_attachments_styles missing_styles[klass_sym][attachment_name.to_sym].map!(&:to_s).sort!.map!(&:to_sym).uniq! end end - end + end end end - end diff --git a/lib/paperclip/processor.rb b/lib/paperclip/processor.rb index ac4fd3d47..97283c5e0 100644 --- a/lib/paperclip/processor.rb +++ b/lib/paperclip/processor.rb @@ -7,13 +7,14 @@ module Paperclip # Processors are required to be defined inside the Paperclip module and # are also required to be a subclass of Paperclip::Processor. There is # only one method you *must* implement to properly be a subclass: - # #make, but #initialize may also be of use. Both methods accept 3 + # #make, but #initialize may also be of use. #initialize accepts 3 # arguments: the file that will be operated on (which is an instance of # File), a hash of options that were defined in has_attached_file's - # style hash, and the Paperclip::Attachment itself. + # style hash, and the Paperclip::Attachment itself. These are set as + # instance variables that can be used within `#make`. # - # All #make needs to return is an instance of File (Tempfile is - # acceptable) which contains the results of the processing. + # #make must return an instance of File (Tempfile is acceptable) which + # contains the results of the processing. # # See Paperclip.run for more information about using command-line # utilities from within Processors. @@ -32,27 +33,25 @@ def make def self.make file, options = {}, attachment = nil new(file, options, attachment).make end - end - # Due to how ImageMagick handles its image format conversion and how Tempfile - # handles its naming scheme, it is necessary to override how Tempfile makes - # its names so as to allow for file extensions. Idea taken from the comments - # on this blog post: - # http://marsorange.com/archives/of-mogrify-ruby-tempfile-dynamic-class-definitions - class Tempfile < ::Tempfile - # This is Ruby 1.8.7's implementation. - if RUBY_VERSION <= "1.8.6" || RUBY_PLATFORM =~ /java/ - def make_tmpname(basename, n) - case basename - when Array - prefix, suffix = *basename - else - prefix, suffix = basename, '' - end + # The convert method runs the convert binary with the provided arguments. + # See Paperclip.run for the available options. + def convert(arguments = "", local_options = {}) + Paperclip.run( + Paperclip.options[:is_windows] ? "magick convert" : "convert", + arguments, + local_options, + ) + end - t = Time.now.strftime("%y%m%d") - path = "#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}-#{n}#{suffix}" - end + # The identify method runs the identify binary with the provided arguments. + # See Paperclip.run for the available options. + def identify(arguments = "", local_options = {}) + Paperclip.run( + Paperclip.options[:is_windows] ? "magick identify" : "identify", + arguments, + local_options, + ) end end end diff --git a/lib/paperclip/processor_helpers.rb b/lib/paperclip/processor_helpers.rb new file mode 100644 index 000000000..d20c1df76 --- /dev/null +++ b/lib/paperclip/processor_helpers.rb @@ -0,0 +1,50 @@ +module Paperclip + module ProcessorHelpers + class NoSuchProcessor < StandardError; end + + def processor(name) #:nodoc: + @known_processors ||= {} + if @known_processors[name.to_s] + @known_processors[name.to_s] + else + name = name.to_s.camelize + load_processor(name) unless Paperclip.const_defined?(name) + processor = Paperclip.const_get(name) + @known_processors[name.to_s] = processor + end + end + + def load_processor(name) + if defined?(Rails.root) && Rails.root + filename = "#{name.to_s.underscore}.rb" + directories = %w(lib/paperclip lib/paperclip_processors) + + required = directories.map do |directory| + pathname = File.expand_path(Rails.root.join(directory, filename)) + file_exists = File.exist?(pathname) + require pathname if file_exists + file_exists + end + + raise LoadError, "Could not find the '#{name}' processor in any of these paths: #{directories.join(', ')}" unless required.any? + end + end + + def clear_processors! + @known_processors.try(:clear) + end + + # You can add your own processor via the Paperclip configuration. Normally + # Paperclip will load all processors from the + # Rails.root/lib/paperclip_processors directory, but here you can add any + # existing class using this mechanism. + # + # Paperclip.configure do |c| + # c.register_processor :watermarker, WatermarkingProcessor.new + # end + def register_processor(name, processor) + @known_processors ||= {} + @known_processors[name.to_s] = processor + end + end +end diff --git a/lib/paperclip/rails_environment.rb b/lib/paperclip/rails_environment.rb new file mode 100644 index 000000000..73e653ed8 --- /dev/null +++ b/lib/paperclip/rails_environment.rb @@ -0,0 +1,25 @@ +module Paperclip + class RailsEnvironment + def self.get + new.get + end + + def get + if rails_exists? && rails_environment_exists? + Rails.env + else + nil + end + end + + private + + def rails_exists? + Object.const_defined?(:Rails) + end + + def rails_environment_exists? + Rails.respond_to?(:env) + end + end +end diff --git a/lib/paperclip/railtie.rb b/lib/paperclip/railtie.rb index d691daeb8..42a657226 100644 --- a/lib/paperclip/railtie.rb +++ b/lib/paperclip/railtie.rb @@ -2,34 +2,30 @@ require 'paperclip/schema' module Paperclip - if defined? Rails::Railtie - require 'rails' - class Railtie < Rails::Railtie - initializer 'paperclip.insert_into_active_record' do - ActiveSupport.on_load :active_record do - Paperclip::Railtie.insert - end + require 'rails' + + class Railtie < Rails::Railtie + initializer 'paperclip.insert_into_active_record' do |app| + ActiveSupport.on_load :active_record do + Paperclip::Railtie.insert end - rake_tasks do - load "tasks/paperclip.rake" + + if app.config.respond_to?(:paperclip_defaults) + Paperclip::Attachment.default_options.merge!(app.config.paperclip_defaults) end end + + rake_tasks { load "tasks/paperclip.rake" } end class Railtie def self.insert - Paperclip.options[:logger] = Rails.logger if defined?(Rails) - + Paperclip.options[:logger] = Rails.logger + if defined?(ActiveRecord) - ActiveRecord::Base.send(:include, Paperclip::Glue) Paperclip.options[:logger] = ActiveRecord::Base.logger - - ActiveRecord::ConnectionAdapters::AbstractAdapter.send(:include, Paperclip::Schema) - ActiveRecord::ConnectionAdapters::Table.send(:include, Paperclip::Schema) - ActiveRecord::ConnectionAdapters::TableDefinition.send(:include, Paperclip::Schema) + ActiveRecord::Base.send(:include, Paperclip::Glue) end - - File.send(:include, Paperclip::Upfile) end end end diff --git a/lib/paperclip/schema.rb b/lib/paperclip/schema.rb index 012c17ef9..a04473edf 100644 --- a/lib/paperclip/schema.rb +++ b/lib/paperclip/schema.rb @@ -1,39 +1,77 @@ +require 'active_support/deprecation' + module Paperclip - # Provides two helpers that can be used in migrations. - # - # In order to use this module, the target class should implement a - # +column+ method that takes the column name and type, both as symbols, - # as well as a +remove_column+ method that takes a table and column name, - # also both symbols. + # Provides helper methods that can be used in migrations. module Schema - @@columns = {:file_name => :string, - :content_type => :string, - :file_size => :integer, - :updated_at => :datetime} - - def has_attached_file(attachment_name) - with_columns_for(attachment_name) do |column_name, column_type| - column(column_name, column_type) - end + COLUMNS = {:file_name => :string, + :content_type => :string, + :file_size => :integer, + :updated_at => :datetime} + + def self.included(base) + ActiveRecord::ConnectionAdapters::Table.send :include, TableDefinition + ActiveRecord::ConnectionAdapters::TableDefinition.send :include, TableDefinition + ActiveRecord::ConnectionAdapters::AbstractAdapter.send :include, Statements + ActiveRecord::Migration::CommandRecorder.send :include, CommandRecorder end - def drop_attached_file(table_name, attachment_name) - with_columns_for(attachment_name) do |column_name, column_type| - remove_column(table_name, column_name) + module Statements + def add_attachment(table_name, *attachment_names) + raise ArgumentError, "Please specify attachment name in your add_attachment call in your migration." if attachment_names.empty? + + options = attachment_names.extract_options! + + attachment_names.each do |attachment_name| + COLUMNS.each_pair do |column_name, column_type| + column_options = options.merge(options[column_name.to_sym] || {}) + add_column(table_name, "#{attachment_name}_#{column_name}", column_type, column_options) + end + end + end + + def remove_attachment(table_name, *attachment_names) + raise ArgumentError, "Please specify attachment name in your remove_attachment call in your migration." if attachment_names.empty? + + attachment_names.each do |attachment_name| + COLUMNS.keys.each do |column_name| + remove_column(table_name, "#{attachment_name}_#{column_name}") + end + end + end + + def drop_attached_file(*args) + ActiveSupport::Deprecation.warn "Method `drop_attached_file` in the migration has been deprecated and will be replaced by `remove_attachment`." + remove_attachment(*args) end end - protected + module TableDefinition + def attachment(*attachment_names) + options = attachment_names.extract_options! + attachment_names.each do |attachment_name| + COLUMNS.each_pair do |column_name, column_type| + column_options = options.merge(options[column_name.to_sym] || {}) + column("#{attachment_name}_#{column_name}", column_type, column_options) + end + end + end - def with_columns_for(attachment_name) - @@columns.each do |suffix, column_type| - column_name = full_column_name(attachment_name, suffix) - yield column_name, column_type + def has_attached_file(*attachment_names) + ActiveSupport::Deprecation.warn "Method `t.has_attached_file` in the migration has been deprecated and will be replaced by `t.attachment`." + attachment(*attachment_names) end end - def full_column_name(attachment_name, column_name) - "#{attachment_name}_#{column_name}".to_sym + module CommandRecorder + def add_attachment(*args) + record(:add_attachment, args) + end + + private + + def invert_add_attachment(args) + [:remove_attachment, args] + end end end end diff --git a/lib/paperclip/storage/filesystem.rb b/lib/paperclip/storage/filesystem.rb index c48ee59df..5bed9ad03 100644 --- a/lib/paperclip/storage/filesystem.rb +++ b/lib/paperclip/storage/filesystem.rb @@ -3,7 +3,7 @@ module Storage # The default place to store attachments is in the filesystem. Files on the local # filesystem can be very easily served by Apache without requiring a hit to your app. # They also can be processed more easily after they've been saved, as they're just - # normal files. There is one Filesystem-specific option for has_attached_file. + # normal files. There are two Filesystem-specific options for has_attached_file: # * +path+: The location of the repository of attachments on disk. This can (and, in # almost all cases, should) be coordinated with the value of the +url+ option to # allow files to be saved into a place where Apache can serve them without @@ -15,6 +15,12 @@ module Storage # public directory. # See Paperclip::Attachment#interpolate for more information on variable interpolaton. # :path => "/var/app/attachments/:class/:id/:style/:basename.:extension" + # * +override_file_permissions+: This allows you to override the file permissions for files + # saved by paperclip. If you set this to an explicit octal value (0755, 0644, etc) then + # that value will be used to set the permissions for an uploaded file. The default is 0666. + # If you set :override_file_permissions to false, the chmod will be skipped. This allows + # you to use paperclip on filesystems that don't understand unix file permissions, and has the + # added benefit of using the storage directories default umask on those that do. module Filesystem def self.extended base end @@ -27,24 +33,23 @@ def exists?(style_name = default_style) end end - # Returns representation of the data of the file assigned to the given - # style, in the format most representative of the current storage. - def to_file style_name = default_style - if @queued_for_write[style_name] - @queued_for_write[style_name].rewind - @queued_for_write[style_name] - elsif exists?(style_name) - File.new(path(style_name), 'rb') - end - end - def flush_writes #:nodoc: @queued_for_write.each do |style_name, file| - file.close FileUtils.mkdir_p(File.dirname(path(style_name))) - log("saving #{path(style_name)}") - FileUtils.cp(file.path, path(style_name)) - FileUtils.chmod(0666&~File.umask, path(style_name)) + begin + move_file(file.path, path(style_name)) + rescue SystemCallError + File.open(path(style_name), "wb") do |new_file| + while chunk = file.read(16 * 1024) + new_file.write(chunk) + end + end + end + unless @options[:override_file_permissions] == false + resolved_chmod = (@options[:override_file_permissions] & ~0111) || (0666 & ~File.umask) + FileUtils.chmod( resolved_chmod, path(style_name) ) + end + file.rewind end after_flush_writes # allows attachment to clean up temp files @@ -64,7 +69,7 @@ def flush_deletes #:nodoc: while(true) path = File.dirname(path) FileUtils.rmdir(path) - break if File.exists?(path) # Ruby 1.9.2 does not raise if the removal failed. + break if File.exist?(path) # Ruby 1.9.2 does not raise if the removal failed. end rescue Errno::EEXIST, Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL, Errno::ENOTDIR, Errno::EACCES # Stop trying to remove parent directories @@ -75,6 +80,21 @@ def flush_deletes #:nodoc: end @queued_for_delete = [] end + + def copy_to_local_file(style, local_dest_path) + FileUtils.cp(path(style), local_dest_path) + end + + private + + def move_file(src, dest) + # Support hardlinked files + if File.identical?(src, dest) + File.unlink(src) + else + FileUtils.mv(src, dest) + end + end end end diff --git a/lib/paperclip/storage/fog.rb b/lib/paperclip/storage/fog.rb index 3d7f1b916..ae4d1636d 100644 --- a/lib/paperclip/storage/fog.rb +++ b/lib/paperclip/storage/fog.rb @@ -14,10 +14,13 @@ module Storage # aws_secret_access_key: '' # provider: 'AWS' # region: 'eu-west-1' + # scheme: 'https' # * +fog_directory+: This is the name of the S3 bucket that will # store your files. Remember that the bucket must be unique across # all of Amazon S3. If the bucket does not exist, Paperclip will # attempt to create it. + # * +fog_file+: This can be hash or lambda returning hash. The + # value is used as base properties for new uploaded file. # * +path+: This is the key under the bucket in which the file will # be stored. The URL will be constructed from the bucket and the # path. This is what you will want to interpolate. Keys should be @@ -30,6 +33,10 @@ module Storage # that is the alias to the S3 domain of your bucket, e.g. # 'http://images.example.com'. This can also be used in # conjunction with Cloudfront (http://aws.amazon.com/cloudfront) + # * +fog_options+: (optional) A hash of options that are passed + # to fog when the file is created. For example, you could set + # the multipart-chunk size to 100MB with a hash: + # { :multipart_chunk_size => 104857600 } module Fog def self.extended base @@ -41,8 +48,8 @@ def self.extended base end unless defined?(Fog) base.instance_eval do - unless @options[:url].to_s.match(/^:fog.*url$/) - @options[:path] = @options[:path].gsub(/:url/, @options[:url]) + unless @options[:url].to_s.match(/\A:fog.*url\z/) + @options[:path] = @options[:path].gsub(/:url/, @options[:url]).gsub(/\A:rails_root\/public\/system\//, '') @options[:url] = ':fog_public_url' end Paperclip.interpolates(:fog_public_url) do |attachment, style| @@ -51,7 +58,7 @@ def self.extended base end end - AWS_BUCKET_SUBDOMAIN_RESTRICTON_REGEX = /^(?:[a-z]|\d(?!\d{0,2}(?:\.\d{1,3}){3}$))(?:[a-z0-9]|\.(?![\.\-])|\-(?![\.])){1,61}[a-z0-9]$/ + AWS_BUCKET_SUBDOMAIN_RESTRICTON_REGEX = /\A(?:[a-z]|\d(?!\d{0,2}(?:\.\d{1,3}){3}\z))(?:[a-z0-9]|\.(?![\.\-])|\-(?![\.])){1,61}[a-z0-9]\z/ def exists?(style = default_style) if original_filename @@ -66,12 +73,31 @@ def fog_credentials end def fog_file - @fog_file ||= @options[:fog_file] || {} + @fog_file ||= begin + value = @options[:fog_file] + if !value + {} + elsif value.respond_to?(:call) + value.call(self) + else + value + end + end end - def fog_public - return @fog_public if defined?(@fog_public) - @fog_public = defined?(@options[:fog_public]) ? @options[:fog_public] : true + def fog_public(style = default_style) + if @options.key?(:fog_public) + value = @options[:fog_public] + if value.respond_to?(:key?) && value.key?(style) + value[style] + elsif value.respond_to?(:call) + value.call(self) + else + value + end + else + true + end end def flush_writes @@ -79,17 +105,22 @@ def flush_writes log("saving #{path(style)}") retried = false begin - directory.files.create(fog_file.merge( + attributes = fog_file.merge( :body => file, :key => path(style), - :public => fog_public, - :content_type => file.content_type.to_s.strip - )) + :public => fog_public(style), + :content_type => file.content_type + ) + attributes.merge!(@options[:fog_options]) if @options[:fog_options] + directory.files.create(attributes) rescue Excon::Errors::NotFound raise if retried retried = true directory.save + file.rewind retry + ensure + file.rewind end end @@ -106,56 +137,76 @@ def flush_deletes @queued_for_delete = [] end - # Returns representation of the data of the file assigned to the given - # style, in the format most representative of the current storage. - def to_file(style = default_style) - if @queued_for_write[style] - @queued_for_write[style].rewind - @queued_for_write[style] - else - body = directory.files.get(path(style)).body - filename = path(style) - extname = File.extname(filename) - basename = File.basename(filename, extname) - file = Tempfile.new([basename, extname]) - file.binmode - file.write(body) - file.rewind - file - end - end - def public_url(style = default_style) if @options[:fog_host] - host = if @options[:fog_host].respond_to?(:call) - @options[:fog_host].call(self) - else - (@options[:fog_host] =~ /%d/) ? @options[:fog_host] % (path(style).hash % 4) : @options[:fog_host] - end - - "#{host}/#{path(style)}" + "#{dynamic_fog_host_for_style(style)}/#{path(style)}" else if fog_credentials[:provider] == 'AWS' - if @options[:fog_directory].to_s =~ Fog::AWS_BUCKET_SUBDOMAIN_RESTRICTON_REGEX - "https://#{@options[:fog_directory]}.s3.amazonaws.com/#{path(style)}" - else - # directory is not a valid subdomain, so use path style for access - "https://s3.amazonaws.com/#{@options[:fog_directory]}/#{path(style)}" - end + "#{scheme}://#{host_name_for_directory}/#{path(style)}" else directory.files.new(:key => path(style)).public_url end end end + def expiring_url(time = (Time.now + 3600), style_name = default_style) + time = convert_time(time) + http_url_method = "get_#{scheme}_url" + if path(style_name) && directory.files.respond_to?(http_url_method) + expiring_url = directory.files.public_send(http_url_method, path(style_name), time) + + if @options[:fog_host] + expiring_url.gsub!(/#{host_name_for_directory}/, dynamic_fog_host_for_style(style_name)) + end + else + expiring_url = url(style_name) + end + + return expiring_url + end + def parse_credentials(creds) creds = find_credentials(creds).stringify_keys - env = Object.const_defined?(:Rails) ? Rails.env : nil - (creds[env] || creds).symbolize_keys + (creds[RailsEnvironment.get] || creds).symbolize_keys + end + + def copy_to_local_file(style, local_dest_path) + log("copying #{path(style)} to local file #{local_dest_path}") + ::File.open(local_dest_path, 'wb') do |local_file| + file = directory.files.get(path(style)) + return false unless file + local_file.write(file.body) + end + rescue ::Fog::Errors::Error => e + warn("#{e} - cannot copy #{path(style)} to local file #{local_dest_path}") + false end private + def convert_time(time) + if time.is_a?(Integer) + time = Time.now + time + end + time + end + + def dynamic_fog_host_for_style(style) + if @options[:fog_host].respond_to?(:call) + @options[:fog_host].call(self) + else + (@options[:fog_host] =~ /%d/) ? @options[:fog_host] % (path(style).hash % 4) : @options[:fog_host] + end + end + + def host_name_for_directory + if directory_name.to_s =~ Fog::AWS_BUCKET_SUBDOMAIN_RESTRICTON_REGEX + "#{directory_name}.s3.amazonaws.com" + else + "s3.amazonaws.com/#{directory_name}" + end + end + def find_credentials(creds) case creds when File @@ -178,13 +229,19 @@ def connection end def directory - dir = if @options[:fog_directory].respond_to?(:call) + @directory ||= connection.directories.new(key: directory_name) + end + + def directory_name + if @options[:fog_directory].respond_to?(:call) @options[:fog_directory].call(self) else @options[:fog_directory] end - - @directory ||= connection.directories.new(:key => dir) + end + + def scheme + @scheme ||= fog_credentials[:scheme] || 'https' end end end diff --git a/lib/paperclip/storage/s3.rb b/lib/paperclip/storage/s3.rb index aec2c9b10..b387edc21 100644 --- a/lib/paperclip/storage/s3.rb +++ b/lib/paperclip/storage/s3.rb @@ -3,10 +3,10 @@ module Storage # Amazon's S3 file hosting service is a scalable, easy place to store files for # distribution. You can find out more about it at http://aws.amazon.com/s3 # - # To use Paperclip with S3, include the +aws-sdk+ gem in your Gemfile: - # gem 'aws-sdk' + # To use Paperclip with S3, include the +aws-sdk-s3+ gem in your Gemfile: + # gem 'aws-sdk-s3' # There are a few S3-specific options for has_attached_file: - # * +s3_credentials+: Takes a path, a File, or a Hash. The path (or File) must point + # * +s3_credentials+: Takes a path, a File, a Hash or a Proc. The path (or File) must point # to a YAML file containing the +access_key_id+ and +secret_access_key+ that Amazon # gives you. You can 'environment-space' this just like you do to your # database.yml file, so different environments can use different accounts: @@ -26,32 +26,48 @@ module Storage # put your bucket name in this file, instead of adding it to the code directly. # This is useful when you want the same account but a different bucket for # development versus production. + # When using a Proc it provides a single parameter which is the attachment itself. A + # method #instance is available on the attachment which will take you back to your + # code. eg. + # class User + # has_attached_file :download, + # :storage => :s3, + # :s3_credentials => Proc.new{|a| a.instance.s3_credentials } + # + # def s3_credentials + # {:bucket => "xxx", :access_key_id => "xxx", :secret_access_key => "xxx"} + # end + # end # * +s3_permissions+: This is a String that should be one of the "canned" access # policies that S3 provides (more information can be found here: - # http://docs.amazonwebservices.com/AmazonS3/latest/dev/index.html?RESTAccessPolicy.html) - # The default for Paperclip is :public_read. + # http://docs.aws.amazon.com/AmazonS3/latest/dev/ACLOverview.html) + # The default for Paperclip is public-read. # # You can set permission on a per style bases by doing the following: # :s3_permissions => { - # :original => :private + # :original => "private" # } - # Or globaly: - # :s3_permissions => :private + # Or globally: + # :s3_permissions => "private" # - # * +s3_protocol+: The protocol for the URLs generated to your S3 assets. Can be either - # 'http' or 'https'. Defaults to 'http' when your :s3_permissions are :public_read (the - # default), and 'https' when your :s3_permissions are anything else. + # * +s3_protocol+: The protocol for the URLs generated to your S3 assets. + # Can be either 'http', 'https', or an empty string to generate + # protocol-relative URLs. Defaults to empty string. # * +s3_headers+: A hash of headers or a Proc. You may specify a hash such as # {'Expires' => 1.year.from_now.httpdate}. If you use a Proc, headers are determined at # runtime. Paperclip will call that Proc with attachment as the only argument. + # Can be defined both globally and within a style-specific hash. # * +bucket+: This is the name of the S3 bucket that will store your files. Remember # that the bucket must be unique across all of Amazon S3. If the bucket does not exist # Paperclip will attempt to create it. The bucket name will not be interpolated. - # You can define the bucket as a Proc if you want to determine it's name at runtime. + # You can define the bucket as a Proc if you want to determine its name at runtime. # Paperclip will call that Proc with attachment as the only argument. # * +s3_host_alias+: The fully-qualified domain name (FQDN) that is the alias to the # S3 domain of your bucket. Used with the :s3_alias_url url interpolation. See the # link in the +url+ entry for more information about S3 domains and buckets. + # * +s3_prefixes_in_alias+: The number of prefixes that is prepended by + # s3_host_alias. This will remove the prefixes from the path in + # :s3_alias_url url interpolation # * +url+: There are four options for the S3 url. You can choose to have the bucket's name # placed domain-style (bucket.s3.amazonaws.com) or path-style (s3.amazonaws.com/bucket). # You can also specify a CNAME (which requires the CNAME to be specified as @@ -60,88 +76,117 @@ module Storage # Normally, this won't matter in the slightest and you can leave the default (which is # path-style, or :s3_path_url). But in some cases paths don't work and you need to use # the domain-style (:s3_domain_url). Anything else here will be treated like path-style. - # NOTE: If you use a CNAME for use with CloudFront, you can NOT specify https as your - # :s3_protocol; This is *not supported* by S3/CloudFront. Finally, when using the host - # alias, the :bucket parameter is ignored, as the hostname is used as the bucket name - # by S3. The fourth option for the S3 url is :asset_host, which uses Rails' built-in - # asset_host settings. NOTE: To get the full url from a paperclip'd object, use the - # image_path helper; this is what image_tag uses to generate the url for an img tag. + # + # Notes: + # * The value of this option is a string, not a symbol. + # right: ":s3_domain_url" + # wrong: :s3_domain_url + # * If you use a CNAME for use with CloudFront, you can NOT specify https as your + # :s3_protocol; + # This is *not supported* by S3/CloudFront. Finally, when using the host + # alias, the :bucket parameter is ignored, as the hostname is used as the bucket name + # by S3. The fourth option for the S3 url is :asset_host, which uses Rails' built-in + # asset_host settings. + # * To get the full url from a paperclip'd object, use the + # image_path helper; this is what image_tag uses to generate the url for an img tag. # * +path+: This is the key under the bucket in which the file will be stored. The # URL will be constructed from the bucket and the path. This is what you will want # to interpolate. Keys should be unique, like filenames, and despite the fact that # S3 (strictly speaking) does not support directories, you can still use a / to # separate parts of your file name. - # * +s3_host_name+: If you are using your bucket in Tokyo region etc, write host_name. + # * +s3_host_name+: If you are using your bucket in Tokyo region + # etc, write host_name (e.g., 's3-ap-northeast-1.amazonaws.com'). + # * +s3_region+: For aws-sdk-s3, s3_region is required. # * +s3_metadata+: These key/value pairs will be stored with the # object. This option works by prefixing each key with # "x-amz-meta-" before sending it as a header on the object - # upload request. + # upload request. Can be defined both globally and within a style-specific hash. # * +s3_storage_class+: If this option is set to - # :reduced_redundancy, the object will be stored using Reduced - # Redundancy Storage. RRS enables customers to reduce their + # :REDUCED_REDUNDANCY, the object will be stored using Reduced + # Redundancy Storage. RRS enables customers to reduce their # costs by storing non-critical, reproducible data at lower # levels of redundancy than Amazon S3's standard storage. + # * +use_accelerate_endpoint+: Use accelerate endpoint + # http://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html + # + # You can set storage class on a per style bases by doing the following: + # :s3_storage_class => { + # :thumb => :REDUCED_REDUNDANCY + # } + # + # Or globally: + # :s3_storage_class => :REDUCED_REDUNDANCY + # + # Other storage classes, such as :STANDARD_IA, are also available—see the + # documentation for the aws-sdk-s3 gem for the full list. + module S3 def self.extended base begin - require 'aws-sdk' + require "aws-sdk-s3" rescue LoadError => e - e.message << " (You may need to install the aws-sdk gem)" + e.message << " (You may need to install the aws-sdk-s3 gem)" raise e - end unless defined?(AWS::Core) + end base.instance_eval do @s3_options = @options[:s3_options] || {} @s3_permissions = set_permissions(@options[:s3_permissions]) - @s3_protocol = @options[:s3_protocol] || - Proc.new do |style, attachment| - permission = (@s3_permissions[style.to_sym] || @s3_permissions[:default]) - permission = permission.call(attachment, style) if permission.is_a?(Proc) - (permission == :public_read) ? 'http' : 'https' - end + @s3_protocol = @options[:s3_protocol] || "".freeze @s3_metadata = @options[:s3_metadata] || {} - @s3_headers = @options[:s3_headers] || {} - @s3_headers = @s3_headers.call(instance) if @s3_headers.is_a?(Proc) - @s3_headers = (@s3_headers).inject({}) do |headers,(name,value)| - case name.to_s - when /^x-amz-meta-(.*)/i - @s3_metadata[$1.downcase] = value - else - name = name.to_s.downcase.sub(/^x-amz-/,'').tr("-","_").to_sym - headers[name] = value - end - headers - end + @s3_headers = {} + merge_s3_headers(@options[:s3_headers], @s3_headers, @s3_metadata) - @s3_headers[:storage_class] = @options[:s3_storage_class] if @options[:s3_storage_class] + @s3_storage_class = set_storage_class(@options[:s3_storage_class]) - @s3_server_side_encryption = @options[:s3_server_side_encryption] + @s3_server_side_encryption = "AES256" + if @options[:s3_server_side_encryption].blank? + @s3_server_side_encryption = false + end + if @s3_server_side_encryption + @s3_server_side_encryption = @options[:s3_server_side_encryption] + end - unless @options[:url].to_s.match(/^:s3.*url$/) || @options[:url] == ":asset_host" - @options[:path] = @options[:path].gsub(/:url/, @options[:url]).gsub(/^:rails_root\/public\/system/, '') - @options[:url] = ":s3_path_url" + unless @options[:url].to_s.match(/\A:s3.*url\z/) || @options[:url] == ":asset_host".freeze + @options[:path] = path_option.gsub(/:url/, @options[:url]).sub(/\A:rails_root\/public\/system/, "".freeze) + @options[:url] = ":s3_path_url".freeze end @options[:url] = @options[:url].inspect if @options[:url].is_a?(Symbol) @http_proxy = @options[:http_proxy] || nil + + @use_accelerate_endpoint = @options[:use_accelerate_endpoint] end + Paperclip.interpolates(:s3_alias_url) do |attachment, style| - "#{attachment.s3_protocol(style)}://#{attachment.s3_host_alias}/#{attachment.path(style).gsub(%r{^/}, "")}" + protocol = attachment.s3_protocol(style, true) + host = attachment.s3_host_alias + path = attachment.path(style). + split("/")[attachment.s3_prefixes_in_alias..-1]. + join("/"). + sub(%r{\A/}, "".freeze) + "#{protocol}//#{host}/#{path}" end unless Paperclip::Interpolations.respond_to? :s3_alias_url Paperclip.interpolates(:s3_path_url) do |attachment, style| - "#{attachment.s3_protocol(style)}://#{attachment.s3_host_name}/#{attachment.bucket_name}/#{attachment.path(style).gsub(%r{^/}, "")}" + "#{attachment.s3_protocol(style, true)}//#{attachment.s3_host_name}/#{attachment.bucket_name}/#{attachment.path(style).sub(%r{\A/}, "".freeze)}" end unless Paperclip::Interpolations.respond_to? :s3_path_url Paperclip.interpolates(:s3_domain_url) do |attachment, style| - "#{attachment.s3_protocol(style)}://#{attachment.bucket_name}.#{attachment.s3_host_name}/#{attachment.path(style).gsub(%r{^/}, "")}" + "#{attachment.s3_protocol(style, true)}//#{attachment.bucket_name}.#{attachment.s3_host_name}/#{attachment.path(style).sub(%r{\A/}, "".freeze)}" end unless Paperclip::Interpolations.respond_to? :s3_domain_url Paperclip.interpolates(:asset_host) do |attachment, style| - "#{attachment.path(style).gsub(%r{^/}, "")}" + "#{attachment.path(style).sub(%r{\A/}, "".freeze)}" end unless Paperclip::Interpolations.respond_to? :asset_host end def expiring_url(time = 3600, style_name = default_style) - if path - s3_object(style_name).url_for(:read, :expires => time, :secure => use_secure_protocol?(style_name)).to_s + if path(style_name) + base_options = { expires_in: time } + s3_object(style_name).presigned_url( + :get, + base_options.merge(s3_url_options), + ).to_s + else + url(style_name) end end @@ -150,24 +195,44 @@ def s3_credentials end def s3_host_name - @options[:s3_host_name] || s3_credentials[:s3_host_name] || "s3.amazonaws.com" + host_name = @options[:s3_host_name] + host_name = host_name.call(self) if host_name.is_a?(Proc) + + host_name || s3_credentials[:s3_host_name] || "s3.amazonaws.com".freeze + end + + def s3_region + region = @options[:s3_region] + region = region.call(self) if region.is_a?(Proc) + + region || s3_credentials[:s3_region] end def s3_host_alias @s3_host_alias = @options[:s3_host_alias] - @s3_host_alias = @s3_host_alias.call(self) if @s3_host_alias.is_a?(Proc) + @s3_host_alias = @s3_host_alias.call(self) if @s3_host_alias.respond_to?(:call) @s3_host_alias end + def s3_prefixes_in_alias + @s3_prefixes_in_alias ||= @options[:s3_prefixes_in_alias].to_i + end + + def s3_url_options + s3_url_options = @options[:s3_url_options] || {} + s3_url_options = s3_url_options.call(instance) if s3_url_options.respond_to?(:call) + s3_url_options + end + def bucket_name @bucket = @options[:bucket] || s3_credentials[:bucket] - @bucket = @bucket.call(self) if @bucket.is_a?(Proc) + @bucket = @bucket.call(self) if @bucket.respond_to?(:call) @bucket or raise ArgumentError, "missing required :bucket option" end def s3_interface @s3_interface ||= begin - config = { :s3_endpoint => s3_host_name } + config = { region: s3_region } if using_http_proxy? @@ -181,20 +246,35 @@ def s3_interface config[:proxy_uri] = URI::HTTP.build(proxy_opts) end - [:access_key_id, :secret_access_key].each do |opt| + config[:use_accelerate_endpoint] = use_accelerate_endpoint? + + [:access_key_id, :secret_access_key, :credential_provider, :credentials].each do |opt| config[opt] = s3_credentials[opt] if s3_credentials[opt] end - AWS::S3.new(config.merge(@s3_options)) + obtain_s3_instance_for(config.merge(@s3_options)) end end + def obtain_s3_instance_for(options) + instances = (Thread.current[:paperclip_s3_instances] ||= {}) + instances[options] ||= ::Aws::S3::Resource.new(options) + end + def s3_bucket - @s3_bucket ||= s3_interface.buckets[bucket_name] + @s3_bucket ||= s3_interface.bucket(bucket_name) + end + + def style_name_as_path(style_name) + path(style_name).sub(%r{\A/},'') end def s3_object style_name = default_style - s3_bucket.objects[path(style_name).sub(%r{^/},'')] + s3_bucket.object style_name_as_path(style_name) + end + + def use_accelerate_endpoint? + !!@use_accelerate_endpoint end def using_http_proxy? @@ -218,18 +298,19 @@ def http_proxy_password end def set_permissions permissions - if permissions.is_a?(Hash) - permissions[:default] = permissions[:default] || :public_read - else - permissions = { :default => permissions || :public_read } - end - permissions + permissions = { :default => permissions } unless permissions.respond_to?(:merge) + permissions.merge :default => (permissions[:default] || :"public-read") + end + + def set_storage_class(storage_class) + storage_class = {:default => storage_class} unless storage_class.respond_to?(:merge) + storage_class end def parse_credentials creds + creds = creds.respond_to?(:call) ? creds.call(self) : creds creds = find_credentials(creds).stringify_keys - env = Object.const_defined?(:Rails) ? Rails.env : nil - (creds[env] || creds).symbolize_keys + (creds[RailsEnvironment.get] || creds).symbolize_keys end def exists?(style = default_style) @@ -238,64 +319,77 @@ def exists?(style = default_style) else false end - rescue AWS::Errors::Base => e + rescue Aws::Errors::ServiceError => e false end def s3_permissions(style = default_style) s3_permissions = @s3_permissions[style] || @s3_permissions[:default] - s3_permissions = s3_permissions.call(self, style) if s3_permissions.is_a?(Proc) + s3_permissions = s3_permissions.call(self, style) if s3_permissions.respond_to?(:call) s3_permissions end - def s3_protocol(style = default_style) - if @s3_protocol.is_a?(Proc) - @s3_protocol.call(style, self) - else - @s3_protocol - end + def s3_storage_class(style = default_style) + @s3_storage_class[style] || @s3_storage_class[:default] end - # Returns representation of the data of the file assigned to the given - # style, in the format most representative of the current storage. - def to_file style = default_style - if @queued_for_write[style] - @queued_for_write[style].rewind - return @queued_for_write[style] + def s3_protocol(style = default_style, with_colon = false) + protocol = @s3_protocol + protocol = protocol.call(style, self) if protocol.respond_to?(:call) + + if with_colon && !protocol.empty? + "#{protocol}:" + else + protocol.to_s end - filename = path(style) - extname = File.extname(filename) - basename = File.basename(filename, extname) - file = Tempfile.new([basename, extname]) - file.binmode - file.write(s3_object(style).read) - file.rewind - return file end def create_bucket - s3_interface.buckets.create(bucket_name) + s3_interface.bucket(bucket_name).create end def flush_writes #:nodoc: @queued_for_write.each do |style, file| + retries = 0 begin log("saving #{path(style)}") - acl = @s3_permissions[style] || @s3_permissions[:default] - acl = acl.call(self, style) if acl.respond_to?(:call) write_options = { - :content_type => file.content_type.to_s.strip, - :acl => acl + :content_type => file.content_type, + :acl => s3_permissions(style) } - write_options[:metadata] = @s3_metadata unless @s3_metadata.empty? - unless @s3_server_side_encryption.blank? + + # add storage class for this style if defined + storage_class = s3_storage_class(style) + write_options.merge!(:storage_class => storage_class) if storage_class + + if @s3_server_side_encryption write_options[:server_side_encryption] = @s3_server_side_encryption end + + style_specific_options = styles[style] + + if style_specific_options + merge_s3_headers( style_specific_options[:s3_headers], @s3_headers, @s3_metadata) if style_specific_options[:s3_headers] + @s3_metadata.merge!(style_specific_options[:s3_metadata]) if style_specific_options[:s3_metadata] + end + + write_options[:metadata] = @s3_metadata unless @s3_metadata.empty? write_options.merge!(@s3_headers) - s3_object(style).write(file, write_options) - rescue AWS::S3::Errors::NoSuchBucket => e + + s3_object(style).upload_file(file.path, write_options) + rescue ::Aws::S3::Errors::NoSuchBucket create_bucket retry + rescue ::Aws::S3::Errors::SlowDown + retries += 1 + if retries <= 5 + sleep((2 ** retries) * 0.5) + retry + else + raise + end + ensure + file.rewind end end @@ -308,14 +402,28 @@ def flush_deletes #:nodoc: @queued_for_delete.each do |path| begin log("deleting #{path}") - s3_bucket.objects[path.sub(%r{^/},'')].delete - rescue AWS::Errors::Base => e + s3_bucket.object(path.sub(%r{\A/}, "")).delete + rescue Aws::Errors::ServiceError => e # Ignore this. end end @queued_for_delete = [] end + def copy_to_local_file(style, local_dest_path) + log("copying #{path(style)} to local file #{local_dest_path}") + ::File.open(local_dest_path, 'wb') do |local_file| + s3_object(style).get do |chunk| + local_file.write(chunk) + end + end + rescue Aws::Errors::ServiceError => e + warn("#{e} - cannot copy #{path(style)} to local file #{local_dest_path}") + false + end + + private + def find_credentials creds case creds when File @@ -324,24 +432,29 @@ def find_credentials creds YAML::load(ERB.new(File.read(creds)).result) when Hash creds + when NilClass + {} else - raise ArgumentError, "Credentials are not a path, file, or hash." + raise ArgumentError, "Credentials given are not a path, file, proc, or hash." end end - private :find_credentials - - def establish_connection! - @connection ||= AWS::S3::Base.establish_connection!( @s3_options.merge( - :access_key_id => s3_credentials[:access_key_id], - :secret_access_key => s3_credentials[:secret_access_key] - )) - end - private :establish_connection! def use_secure_protocol?(style_name) s3_protocol(style_name) == "https" end - private :use_secure_protocol? + + def merge_s3_headers(http_headers, s3_headers, s3_metadata) + return if http_headers.nil? + http_headers = http_headers.call(instance) if http_headers.respond_to?(:call) + http_headers.inject({}) do |headers,(name,value)| + case name.to_s + when /\Ax-amz-meta-(.*)/i + s3_metadata[$1.downcase] = value + else + s3_headers[name.to_s.downcase.sub(/\Ax-amz-/,'').tr("-","_").to_sym] = value + end + end + end end end end diff --git a/lib/paperclip/style.rb b/lib/paperclip/style.rb index 1b30a1022..6c093805d 100644 --- a/lib/paperclip/style.rb +++ b/lib/paperclip/style.rb @@ -1,4 +1,3 @@ -# encoding: utf-8 module Paperclip # The Style class holds the definition of a thumbnail style, applying # whatever processing is required to normalize the definition and delaying @@ -29,7 +28,7 @@ def initialize name, definition, attachment @geometry, @format = [definition, nil].flatten[0..1] @other_args = {} end - @format = nil if @format.blank? + @format = default_format if @format.blank? end # retrieves from the attachment the processors defined in the has_attached_file call @@ -52,12 +51,12 @@ def whiny? end def convert_options - @convert_options.respond_to?(:call) ? @convert_options.call(attachment.instance) : + @convert_options.respond_to?(:call) ? @convert_options.call(attachment.instance) : (@convert_options || attachment.send(:extra_options_for, name)) end def source_file_options - @source_file_options.respond_to?(:call) ? @source_file_options.call(attachment.instance) : + @source_file_options.respond_to?(:call) ? @source_file_options.call(attachment.instance) : (@source_file_options || attachment.send(:extra_source_file_options_for, name)) end @@ -71,7 +70,7 @@ def geometry # Arguments other than the standard geometry, format etc are just passed through from # initialization and any procs are called here, just before post-processing. def processor_options - args = {} + args = {:style => name} @other_args.each do |k,v| args[k] = v.respond_to?(:call) ? v.call(attachment) : v end @@ -99,5 +98,11 @@ def []=(key, value) end end + # defaults to default format (nil by default) + def default_format + base = attachment.options[:default_format] + base.respond_to?(:call) ? base.call(attachment, name) : base + end + end end diff --git a/lib/paperclip/tempfile.rb b/lib/paperclip/tempfile.rb new file mode 100644 index 000000000..62ac9783c --- /dev/null +++ b/lib/paperclip/tempfile.rb @@ -0,0 +1,43 @@ +module Paperclip + # Overriding some implementation of Tempfile + class Tempfile < ::Tempfile + # Due to how ImageMagick handles its image format conversion and how + # Tempfile handles its naming scheme, it is necessary to override how + # Tempfile makes # its names so as to allow for file extensions. Idea + # taken from the comments on this blog post: + # http://marsorange.com/archives/of-mogrify-ruby-tempfile-dynamic-class-definitions + # + # This is Ruby 1.9.3's implementation. + def make_tmpname(prefix_suffix, n) + if RUBY_PLATFORM =~ /java/ + case prefix_suffix + when String + prefix, suffix = prefix_suffix, '' + when Array + prefix, suffix = *prefix_suffix + else + raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}" + end + + t = Time.now.strftime("%y%m%d") + path = "#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}-#{n}#{suffix}" + else + super + end + end + end + + module TempfileEncoding + # This overrides Tempfile#binmode to make sure that the extenal encoding + # for binary mode is ASCII-8BIT. This behavior is what's in CRuby, but not + # in JRuby + def binmode + set_encoding('ASCII-8BIT') + super + end + end +end + +if RUBY_PLATFORM =~ /java/ + ::Tempfile.send :include, Paperclip::TempfileEncoding +end diff --git a/lib/paperclip/tempfile_factory.rb b/lib/paperclip/tempfile_factory.rb new file mode 100644 index 000000000..00aa5829b --- /dev/null +++ b/lib/paperclip/tempfile_factory.rb @@ -0,0 +1,23 @@ +module Paperclip + class TempfileFactory + + def generate(name = random_name) + @name = name + file = Tempfile.new([basename, extension]) + file.binmode + file + end + + def extension + File.extname(@name) + end + + def basename + Digest::MD5.hexdigest(File.basename(@name, extension)) + end + + def random_name + SecureRandom.uuid + end + end +end diff --git a/lib/paperclip/thumbnail.rb b/lib/paperclip/thumbnail.rb index 4a08ebeff..8a3af14d3 100644 --- a/lib/paperclip/thumbnail.rb +++ b/lib/paperclip/thumbnail.rb @@ -3,10 +3,11 @@ module Paperclip class Thumbnail < Processor attr_accessor :current_geometry, :target_geometry, :format, :whiny, :convert_options, - :source_file_options, :animated + :source_file_options, :animated, :auto_orient, :frame_index # List of formats that we need to preserve animation ANIMATED_FORMATS = %w(gif) + MULTI_FRAME_FORMATS = %w(.mkv .avi .mp4 .mov .mpg .mpeg .gif) # Creates a Thumbnail object set to work on the +file+ given. It # will attempt to transform the image into one defined by +target_geometry+ @@ -25,26 +26,29 @@ class Thumbnail < Processor # +whiny+ - whether to raise an error when processing fails. Defaults to true # +format+ - the desired filename extension # +animated+ - whether to merge all the layers in the image. Defaults to true + # +frame_index+ - the frame index of the source file to render as the thumbnail def initialize(file, options = {}, attachment = nil) super - geometry = options[:geometry] # this is not an option - @file = file + geometry = options[:geometry].to_s @crop = geometry[-1,1] == '#' - @target_geometry = (options[:string_geometry_parser] || Geometry).parse(geometry) - @current_geometry = (options[:file_geometry_parser] || Geometry).from_file(@file) + @target_geometry = options.fetch(:string_geometry_parser, Geometry).parse(geometry) + @current_geometry = options.fetch(:file_geometry_parser, Geometry).from_file(@file) @source_file_options = options[:source_file_options] @convert_options = options[:convert_options] - @whiny = options[:whiny].nil? ? true : options[:whiny] + @whiny = options.fetch(:whiny, true) @format = options[:format] - @animated = options[:animated].nil? ? true : options[:animated] - + @animated = options.fetch(:animated, true) + @auto_orient = options.fetch(:auto_orient, true) + if @auto_orient && @current_geometry.respond_to?(:auto_orient) + @current_geometry.auto_orient + end @source_file_options = @source_file_options.split(/\s+/) if @source_file_options.respond_to?(:split) @convert_options = @convert_options.split(/\s+/) if @convert_options.respond_to?(:split) @current_format = File.extname(@file.path) @basename = File.basename(@file.path, @current_format) - + @frame_index = multi_frame_format? ? options.fetch(:frame_index, 0) : 0 end # Returns true if the +target_geometry+ is meant to crop. @@ -61,8 +65,8 @@ def convert_options? # that contains the new image. def make src = @file - dst = Tempfile.new([@basename, @format ? ".#{@format}" : '']) - dst.binmode + filename = [@basename, @format ? ".#{@format}" : ""].join + dst = TempfileFactory.new.generate(filename) begin parameters = [] @@ -74,11 +78,19 @@ def make parameters = parameters.flatten.compact.join(" ").strip.squeeze(" ") - success = Paperclip.run("convert", parameters, :source => "#{File.expand_path(src.path)}#{'[0]' unless animated?}", :dest => File.expand_path(dst.path)) - rescue Cocaine::ExitStatusError => e - raise PaperclipError, "There was an error processing the thumbnail for #{@basename}" if @whiny - rescue Cocaine::CommandNotFoundError => e - raise Paperclip::CommandNotFoundError.new("Could not run the `convert` command. Please install ImageMagick.") + frame = animated? ? "" : "[#{@frame_index}]" + convert( + parameters, + source: "#{File.expand_path(src.path)}#{frame}", + dest: File.expand_path(dst.path), + ) + rescue Terrapin::ExitStatusError => e + if @whiny + message = "There was an error processing the thumbnail for #{@basename}:\n" + e.message + raise Paperclip::Error, message + end + rescue Terrapin::CommandNotFoundError => e + raise Paperclip::Errors::CommandNotFoundError.new("Could not run the `convert` command. Please install ImageMagick.") end dst @@ -90,16 +102,33 @@ def transformation_command scale, crop = @current_geometry.transformation_to(@target_geometry, crop?) trans = [] trans << "-coalesce" if animated? + trans << "-auto-orient" if auto_orient trans << "-resize" << %["#{scale}"] unless scale.nil? || scale.empty? trans << "-crop" << %["#{crop}"] << "+repage" if crop + trans << '-layers "optimize"' if animated? trans end protected - # Return true if the format is animated + def multi_frame_format? + MULTI_FRAME_FORMATS.include? @current_format + end + def animated? - @animated && ANIMATED_FORMATS.include?(@current_format[1..-1]) && (ANIMATED_FORMATS.include?(@format.to_s) || @format.blank?) + @animated && (ANIMATED_FORMATS.include?(@format.to_s) || @format.blank?) && identified_as_animated? + end + + # Return true if ImageMagick's +identify+ returns an animated format + def identified_as_animated? + if @identified_as_animated.nil? + @identified_as_animated = ANIMATED_FORMATS.include? identify("-format %m :file", :file => "#{@file.path}[0]").to_s.downcase.strip + end + @identified_as_animated + rescue Terrapin::ExitStatusError => e + raise Paperclip::Error, "There was an error running `identify` for #{@basename}" if @whiny + rescue Terrapin::CommandNotFoundError => e + raise Paperclip::Errors::CommandNotFoundError.new("Could not run the `identify` command. Please install ImageMagick.") end end end diff --git a/lib/paperclip/upfile.rb b/lib/paperclip/upfile.rb index df66cdd97..18baf40e5 100644 --- a/lib/paperclip/upfile.rb +++ b/lib/paperclip/upfile.rb @@ -7,14 +7,7 @@ module Paperclip module Upfile # Infer the MIME-type of the file from the extension. def content_type - types = MIME::Types.type_for(self.original_filename) - if types.length == 0 - type_from_file_command - elsif types.length == 1 - types.first.content_type - else - iterate_over_array_to_find_best_option(types) - end + type_from_file_command end def iterate_over_array_to_find_best_option(types) diff --git a/lib/paperclip/url_generator.rb b/lib/paperclip/url_generator.rb index a8b3d684f..83f784979 100644 --- a/lib/paperclip/url_generator.rb +++ b/lib/paperclip/url_generator.rb @@ -1,30 +1,34 @@ require 'uri' +require 'active_support/core_ext/module/delegation' module Paperclip class UrlGenerator - def initialize(attachment, attachment_options) + def initialize(attachment) @attachment = attachment - @attachment_options = attachment_options end def for(style_name, options) - escape_url_as_needed( - timestamp_as_needed( - @attachment_options[:interpolator].interpolate(most_appropriate_url, @attachment, style_name), - options - ), options) + interpolated = attachment_options[:interpolator].interpolate( + most_appropriate_url, @attachment, style_name + ) + + escaped = escape_url_as_needed(interpolated, options) + timestamp_as_needed(escaped, options) end private + attr_reader :attachment + delegate :options, to: :attachment, prefix: true + # This method is all over the place. def default_url - if @attachment_options[:default_url].respond_to?(:call) - @attachment_options[:default_url].call(@attachment) - elsif @attachment_options[:default_url].is_a?(Symbol) - @attachment.instance.send(@attachment_options[:default_url]) + if attachment_options[:default_url].respond_to?(:call) + attachment_options[:default_url].call(@attachment) + elsif attachment_options[:default_url].is_a?(Symbol) + @attachment.instance.send(attachment_options[:default_url]) else - @attachment_options[:default_url] + attachment_options[:default_url] end end @@ -32,7 +36,7 @@ def most_appropriate_url if @attachment.original_filename.nil? default_url else - @attachment_options[:url] + attachment_options[:url] end end @@ -58,7 +62,15 @@ def escape_url_as_needed(url, options) end def escape_url(url) - (url.respond_to?(:escape) ? url.escape : URI.escape(url)).gsub(/(\/.+)\?(.+\.)/, '\1%3F\2') + if url.respond_to?(:escape) + url.escape + else + URI.escape(url).gsub(escape_regex){|m| "%#{m.ord.to_s(16).upcase}" } + end + end + + def escape_regex + /[\?\(\)\[\]\+]/ end end end diff --git a/lib/paperclip/validators.rb b/lib/paperclip/validators.rb new file mode 100644 index 000000000..b5bd2cba5 --- /dev/null +++ b/lib/paperclip/validators.rb @@ -0,0 +1,74 @@ +require 'active_model' +require 'active_support/concern' +require 'active_support/core_ext/array/wrap' +require 'paperclip/validators/attachment_content_type_validator' +require 'paperclip/validators/attachment_file_name_validator' +require 'paperclip/validators/attachment_presence_validator' +require 'paperclip/validators/attachment_size_validator' +require 'paperclip/validators/media_type_spoof_detection_validator' +require 'paperclip/validators/attachment_file_type_ignorance_validator' + +module Paperclip + module Validators + extend ActiveSupport::Concern + + included do + extend HelperMethods + include HelperMethods + end + + ::Paperclip::REQUIRED_VALIDATORS = [AttachmentFileNameValidator, AttachmentContentTypeValidator, AttachmentFileTypeIgnoranceValidator] + + module ClassMethods + # This method is a shortcut to validator classes that is in + # "Attachment...Validator" format. It is almost the same thing as the + # +validates+ method that shipped with Rails, but this is customized to + # be using with attachment validators. This is helpful when you're using + # multiple attachment validators on a single attachment. + # + # Example of using the validator: + # + # validates_attachment :avatar, :presence => true, + # :content_type => { :content_type => "image/jpg" }, + # :size => { :in => 0..10.kilobytes } + # + def validates_attachment(*attributes) + options = attributes.extract_options!.dup + + Paperclip::Validators.constants.each do |constant| + if constant.to_s =~ /\AAttachment(.+)Validator\z/ + validator_kind = $1.underscore.to_sym + + if options.has_key?(validator_kind) + validator_options = options.delete(validator_kind) + validator_options = {} if validator_options == true + conditional_options = options.slice(:if, :unless) + Array.wrap(validator_options).each do |local_options| + method_name = Paperclip::Validators.const_get(constant.to_s).helper_method_name + send(method_name, attributes, local_options.merge(conditional_options)) + end + end + end + end + end + + def validate_before_processing(validator_class, options) + options = options.dup + attributes = options.delete(:attributes) + attributes.each do |attribute| + options[:attributes] = [attribute] + create_validating_before_filter(attribute, validator_class, options) + end + end + + def create_validating_before_filter(attribute, validator_class, options) + if_clause = options.delete(:if) + unless_clause = options.delete(:unless) + send(:"before_#{attribute}_post_process", :if => if_clause, :unless => unless_clause) do |*args| + validator_class.new(options.dup).validate(self) + end + end + + end + end +end diff --git a/lib/paperclip/validators/attachment_content_type_validator.rb b/lib/paperclip/validators/attachment_content_type_validator.rb new file mode 100644 index 000000000..532074a37 --- /dev/null +++ b/lib/paperclip/validators/attachment_content_type_validator.rb @@ -0,0 +1,88 @@ +module Paperclip + module Validators + class AttachmentContentTypeValidator < ActiveModel::EachValidator + def initialize(options) + options[:allow_nil] = true unless options.has_key?(:allow_nil) + super + end + + def self.helper_method_name + :validates_attachment_content_type + end + + def validate_each(record, attribute, value) + base_attribute = attribute.to_sym + attribute = "#{attribute}_content_type".to_sym + value = record.send :read_attribute_for_validation, attribute + + return if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank]) + + validate_whitelist(record, attribute, value) + validate_blacklist(record, attribute, value) + + if record.errors.include? attribute + record.errors[attribute].each do |error| + record.errors.add base_attribute, error + end + end + end + + def validate_whitelist(record, attribute, value) + if allowed_types.present? && allowed_types.none? { |type| type === value } + mark_invalid record, attribute, allowed_types + end + end + + def validate_blacklist(record, attribute, value) + if forbidden_types.present? && forbidden_types.any? { |type| type === value } + mark_invalid record, attribute, forbidden_types + end + end + + def mark_invalid(record, attribute, types) + record.errors.add attribute, :invalid, options.merge(:types => types.join(', ')) + end + + def allowed_types + [options[:content_type]].flatten.compact + end + + def forbidden_types + [options[:not]].flatten.compact + end + + def check_validity! + unless options.has_key?(:content_type) || options.has_key?(:not) + raise ArgumentError, "You must pass in either :content_type or :not to the validator" + end + end + end + + module HelperMethods + # Places ActiveModel validations on the content type of the file + # assigned. The possible options are: + # * +content_type+: Allowed content types. Can be a single content type + # or an array. Each type can be a String or a Regexp. It should be + # noted that Internet Explorer uploads files with content_types that you + # may not expect. For example, JPEG images are given image/pjpeg and + # PNGs are image/x-png, so keep that in mind when determining how you + # match. Allows all by default. + # * +not+: Forbidden content types. + # * +message+: The message to display when the uploaded file has an invalid + # content type. + # * +if+: A lambda or name of an instance method. Validation will only + # be run is this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + # NOTE: If you do not specify an [attachment]_content_type field on your + # model, content_type validation will work _ONLY upon assignment_ and + # re-validation after the instance has been reloaded will always succeed. + # You'll still need to have a virtual attribute (created by +attr_accessor+) + # name +[attachment]_content_type+ to be able to use this validator. + def validates_attachment_content_type(*attr_names) + options = _merge_attributes(attr_names) + validates_with AttachmentContentTypeValidator, options.dup + validate_before_processing AttachmentContentTypeValidator, options.dup + end + end + end +end diff --git a/lib/paperclip/validators/attachment_file_name_validator.rb b/lib/paperclip/validators/attachment_file_name_validator.rb new file mode 100644 index 000000000..2f2556b6e --- /dev/null +++ b/lib/paperclip/validators/attachment_file_name_validator.rb @@ -0,0 +1,80 @@ +module Paperclip + module Validators + class AttachmentFileNameValidator < ActiveModel::EachValidator + def initialize(options) + options[:allow_nil] = true unless options.has_key?(:allow_nil) + super + end + + def self.helper_method_name + :validates_attachment_file_name + end + + def validate_each(record, attribute, value) + base_attribute = attribute.to_sym + attribute = "#{attribute}_file_name".to_sym + value = record.send :read_attribute_for_validation, attribute + + return if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank]) + + validate_whitelist(record, attribute, value) + validate_blacklist(record, attribute, value) + + if record.errors.include? attribute + record.errors[attribute].each do |error| + record.errors.add base_attribute, error + end + end + end + + def validate_whitelist(record, attribute, value) + if allowed.present? && allowed.none? { |type| type === value } + mark_invalid record, attribute, allowed + end + end + + def validate_blacklist(record, attribute, value) + if forbidden.present? && forbidden.any? { |type| type === value } + mark_invalid record, attribute, forbidden + end + end + + def mark_invalid(record, attribute, patterns) + record.errors.add attribute, :invalid, options.merge(:names => patterns.join(', ')) + end + + def allowed + [options[:matches]].flatten.compact + end + + def forbidden + [options[:not]].flatten.compact + end + + def check_validity! + unless options.has_key?(:matches) || options.has_key?(:not) + raise ArgumentError, "You must pass in either :matches or :not to the validator" + end + end + end + + module HelperMethods + # Places ActiveModel validations on the name of the file + # assigned. The possible options are: + # * +matches+: Allowed filename patterns as Regexps. Can be a single one + # or an array. + # * +not+: Forbidden file name patterns, specified the same was as +matches+. + # * +message+: The message to display when the uploaded file has an invalid + # name. + # * +if+: A lambda or name of an instance method. Validation will only + # be run is this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + def validates_attachment_file_name(*attr_names) + options = _merge_attributes(attr_names) + validates_with AttachmentFileNameValidator, options.dup + validate_before_processing AttachmentFileNameValidator, options.dup + end + end + end +end + diff --git a/lib/paperclip/validators/attachment_file_type_ignorance_validator.rb b/lib/paperclip/validators/attachment_file_type_ignorance_validator.rb new file mode 100644 index 000000000..b35b7c33d --- /dev/null +++ b/lib/paperclip/validators/attachment_file_type_ignorance_validator.rb @@ -0,0 +1,29 @@ +require 'active_model/validations/presence' + +module Paperclip + module Validators + class AttachmentFileTypeIgnoranceValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + # This doesn't do anything. It's just to mark that you don't care about + # the file_names or content_types of your incoming attachments. + end + + def self.helper_method_name + :do_not_validate_attachment_file_type + end + end + + module HelperMethods + # Places ActiveModel validations on the presence of a file. + # Options: + # * +if+: A lambda or name of an instance method. Validation will only + # be run if this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + def do_not_validate_attachment_file_type(*attr_names) + options = _merge_attributes(attr_names) + validates_with AttachmentFileTypeIgnoranceValidator, options.dup + end + end + end +end + diff --git a/lib/paperclip/validators/attachment_presence_validator.rb b/lib/paperclip/validators/attachment_presence_validator.rb new file mode 100644 index 000000000..0a6431b28 --- /dev/null +++ b/lib/paperclip/validators/attachment_presence_validator.rb @@ -0,0 +1,30 @@ +require 'active_model/validations/presence' + +module Paperclip + module Validators + class AttachmentPresenceValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if record.send("#{attribute}_file_name").blank? + record.errors.add(attribute, :blank, options) + end + end + + def self.helper_method_name + :validates_attachment_presence + end + end + + module HelperMethods + # Places ActiveModel validations on the presence of a file. + # Options: + # * +if+: A lambda or name of an instance method. Validation will only + # be run if this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + def validates_attachment_presence(*attr_names) + options = _merge_attributes(attr_names) + validates_with AttachmentPresenceValidator, options.dup + validate_before_processing AttachmentPresenceValidator, options.dup + end + end + end +end diff --git a/lib/paperclip/validators/attachment_size_validator.rb b/lib/paperclip/validators/attachment_size_validator.rb new file mode 100644 index 000000000..47e782b5e --- /dev/null +++ b/lib/paperclip/validators/attachment_size_validator.rb @@ -0,0 +1,109 @@ +require 'active_model/validations/numericality' + +module Paperclip + module Validators + class AttachmentSizeValidator < ActiveModel::Validations::NumericalityValidator + AVAILABLE_CHECKS = [:less_than, :less_than_or_equal_to, :greater_than, :greater_than_or_equal_to] + + def initialize(options) + extract_options(options) + super + end + + def self.helper_method_name + :validates_attachment_size + end + + def validate_each(record, attr_name, value) + base_attr_name = attr_name + attr_name = "#{attr_name}_file_size".to_sym + value = record.send(:read_attribute_for_validation, attr_name) + + unless value.blank? + options.slice(*AVAILABLE_CHECKS).each do |option, option_value| + option_value = option_value.call(record) if option_value.is_a?(Proc) + option_value = extract_option_value(option, option_value) + + unless value.send(CHECKS[option], option_value) + error_message_key = options[:in] ? :in_between : option + [ attr_name, base_attr_name ].each do |error_attr_name| + record.errors.add(error_attr_name, error_message_key, filtered_options(value).merge( + :min => min_value_in_human_size(record), + :max => max_value_in_human_size(record), + :count => human_size(option_value) + )) + end + end + end + end + end + + def check_validity! + unless (AVAILABLE_CHECKS + [:in]).any? { |argument| options.has_key?(argument) } + raise ArgumentError, "You must pass either :less_than, :greater_than, or :in to the validator" + end + end + + private + + def extract_options(options) + if range = options[:in] + if !options[:in].respond_to?(:call) + options[:less_than_or_equal_to] = range.max + options[:greater_than_or_equal_to] = range.min + else + options[:less_than_or_equal_to] = range + options[:greater_than_or_equal_to] = range + end + end + end + + def extract_option_value(option, option_value) + if option_value.is_a?(Range) + if [:less_than, :less_than_or_equal_to].include?(option) + option_value.max + else + option_value.min + end + else + option_value + end + end + + def human_size(size) + ActiveSupport::NumberHelper.number_to_human_size(size) + end + + def min_value_in_human_size(record) + value = options[:greater_than_or_equal_to] || options[:greater_than] + value = value.call(record) if value.respond_to?(:call) + value = value.min if value.respond_to?(:min) + human_size(value) + end + + def max_value_in_human_size(record) + value = options[:less_than_or_equal_to] || options[:less_than] + value = value.call(record) if value.respond_to?(:call) + value = value.max if value.respond_to?(:max) + human_size(value) + end + end + + module HelperMethods + # Places ActiveModel validations on the size of the file assigned. The + # possible options are: + # * +in+: a Range of bytes (i.e. +1..1.megabyte+), + # * +less_than+: equivalent to :in => 0..options[:less_than] + # * +greater_than+: equivalent to :in => options[:greater_than]..Infinity + # * +message+: error message to display, use :min and :max as replacements + # * +if+: A lambda or name of an instance method. Validation will only + # be run if this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + def validates_attachment_size(*attr_names) + options = _merge_attributes(attr_names) + validates_with AttachmentSizeValidator, options.dup + validate_before_processing AttachmentSizeValidator, options.dup + end + end + end +end diff --git a/lib/paperclip/validators/media_type_spoof_detection_validator.rb b/lib/paperclip/validators/media_type_spoof_detection_validator.rb new file mode 100644 index 000000000..d3699acc1 --- /dev/null +++ b/lib/paperclip/validators/media_type_spoof_detection_validator.rb @@ -0,0 +1,27 @@ +require 'active_model/validations/presence' + +module Paperclip + module Validators + class MediaTypeSpoofDetectionValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + adapter = Paperclip.io_adapters.for(value) + if Paperclip::MediaTypeSpoofDetector.using(adapter, value.original_filename, value.content_type).spoofed? + record.errors.add(attribute, :spoofed_media_type) + end + end + end + + module HelperMethods + # Places ActiveModel validations on the presence of a file. + # Options: + # * +if+: A lambda or name of an instance method. Validation will only + # be run if this lambda or method returns true. + # * +unless+: Same as +if+ but validates if lambda or method returns false. + def validates_media_type_spoof_detection(*attr_names) + options = _merge_attributes(attr_names) + validates_with MediaTypeSpoofDetectionValidator, options.dup + validate_before_processing MediaTypeSpoofDetectionValidator, options.dup + end + end + end +end diff --git a/lib/paperclip/version.rb b/lib/paperclip/version.rb index 913366e04..8e7d734e0 100644 --- a/lib/paperclip/version.rb +++ b/lib/paperclip/version.rb @@ -1,3 +1,5 @@ module Paperclip - VERSION = "2.7.0" unless defined? Paperclip::VERSION + unless defined?(Paperclip::VERSION) + VERSION = "6.0.0".freeze + end end diff --git a/lib/tasks/paperclip.rake b/lib/tasks/paperclip.rake index bca5f6121..47c4a7ab2 100644 --- a/lib/tasks/paperclip.rake +++ b/lib/tasks/paperclip.rake @@ -1,3 +1,5 @@ +require 'paperclip/attachment_registry' + module Paperclip module Task def self.obtain_class @@ -9,13 +11,23 @@ module Paperclip def self.obtain_attachments(klass) klass = Paperclip.class_for(klass.to_s) name = ENV['ATTACHMENT'] || ENV['attachment'] - raise "Class #{klass.name} has no attachments specified" unless klass.respond_to?(:attachment_definitions) - if !name.blank? && klass.attachment_definitions.keys.include?(name) + + attachment_names = Paperclip::AttachmentRegistry.names_for(klass) + + if attachment_names.empty? + raise "Class #{klass.name} has no attachments specified" + end + + if name.present? && attachment_names.map(&:to_s).include?(name.to_s) [ name ] else - klass.attachment_definitions.keys + attachment_names end end + + def self.log_error(error) + $stderr.puts error + end end end @@ -26,17 +38,24 @@ namespace :paperclip do namespace :refresh do desc "Regenerates thumbnails for a given CLASS (and optional ATTACHMENT and STYLES splitted by comma)." task :thumbnails => :environment do - errors = [] klass = Paperclip::Task.obtain_class names = Paperclip::Task.obtain_attachments(klass) styles = (ENV['STYLES'] || ENV['styles'] || '').split(',').map(&:to_sym) names.each do |name| Paperclip.each_instance_with_attachment(klass, name) do |instance| - instance.send(name).reprocess!(*styles) - errors << [instance.id, instance.errors] unless instance.errors.blank? + attachment = instance.send(name) + begin + attachment.reprocess!(*styles) + rescue StandardError => e + Paperclip::Task.log_error("exception while processing #{klass} ID #{instance.id}:") + Paperclip::Task.log_error(" " + e.message + "\n") + end + unless instance.errors.blank? + Paperclip::Task.log_error("errors while processing #{klass} ID #{instance.id}:") + Paperclip::Task.log_error(" " + instance.errors.full_messages.join("\n ") + "\n") + end end end - errors.each{|e| puts "#{e.first}: #{e.last.full_messages.inspect}" } end desc "Regenerates content_type/size metadata for a given CLASS (and optional ATTACHMENT)." @@ -45,15 +64,12 @@ namespace :paperclip do names = Paperclip::Task.obtain_attachments(klass) names.each do |name| Paperclip.each_instance_with_attachment(klass, name) do |instance| - if file = instance.send(name).to_file(:original) + attachment = instance.send(name) + if file = Paperclip.io_adapters.for(attachment, attachment.options[:adapter_options]) instance.send("#{name}_file_name=", instance.send("#{name}_file_name").strip) instance.send("#{name}_content_type=", file.content_type.to_s.strip) instance.send("#{name}_file_size=", file.size) if instance.respond_to?("#{name}_file_size") - if Rails.version >= "3.0.0" - instance.save(:validate => false) - else - instance.save(false) - end + instance.save(:validate => false) else true end @@ -63,8 +79,7 @@ namespace :paperclip do desc "Regenerates missing thumbnail styles for all classes using Paperclip." task :missing_styles => :environment do - # Force loading all model classes to never miss any has_attached_file declaration: - Dir[Rails.root + 'app/models/**/*.rb'].each { |path| load path } + Rails.application.eager_load! Paperclip.missing_attachments_styles.each do |klass, attachment_definitions| attachment_definitions.each do |attachment_name, missing_styles| puts "Regenerating #{klass} -> #{attachment_name} -> #{missing_styles.inspect}" @@ -76,6 +91,19 @@ namespace :paperclip do end Paperclip.save_current_attachments_styles! end + + desc "Regenerates fingerprints for a given CLASS (and optional ATTACHMENT). Useful when changing digest." + task :fingerprints => :environment do + klass = Paperclip::Task.obtain_class + names = Paperclip::Task.obtain_attachments(klass) + names.each do |name| + Paperclip.each_instance_with_attachment(klass, name) do |instance| + attachment = instance.send(name) + attachment.assign(attachment) + instance.save(:validate => false) + end + end + end end desc "Cleans out invalid attachments. Useful after you've added new validations." @@ -88,14 +116,26 @@ namespace :paperclip do attributes = %w(file_size file_name content_type).map{ |suffix| "#{name}_#{suffix}".to_sym } if attributes.any?{ |attribute| instance.errors[attribute].present? } instance.send("#{name}=", nil) - if Rails.version >= "3.0.0" - instance.save(:validate => false) - else - instance.save(false) - end + instance.save(:validate => false) end end end end end + + desc "find missing attachments. Useful to know which attachments are broken" + task :find_broken_attachments => :environment do + klass = Paperclip::Task.obtain_class + names = Paperclip::Task.obtain_attachments(klass) + names.each do |name| + Paperclip.each_instance_with_attachment(klass, name) do |instance| + attachment = instance.send(name) + if attachment.exists? + print "." + else + Paperclip::Task.log_error("#{instance.class}##{attachment.name}, #{instance.id}, #{attachment.url}") + end + end + end + end end diff --git a/paperclip.gemspec b/paperclip.gemspec index 990f291e8..94d8e6685 100644 --- a/paperclip.gemspec +++ b/paperclip.gemspec @@ -10,32 +10,45 @@ Gem::Specification.new do |s| s.homepage = "https://github.com/thoughtbot/paperclip" s.summary = "File attachments as attributes for ActiveRecord" s.description = "Easy upload management for ActiveRecord" - - s.rubyforge_project = "paperclip" + s.license = "MIT" s.files = `git ls-files`.split("\n") - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + s.test_files = `git ls-files -- {spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.require_paths = ["lib"] + if File.exist?('UPGRADING') + s.post_install_message = File.read("UPGRADING") + end + s.requirements << "ImageMagick" + s.required_ruby_version = ">= 2.1.0" - s.add_dependency('activerecord', '>= 2.3.0') - s.add_dependency('activesupport', '>= 2.3.2') - s.add_dependency('cocaine', '>= 0.0.2') + s.add_dependency('activemodel', '>= 4.2.0') + s.add_dependency('activesupport', '>= 4.2.0') + s.add_dependency('terrapin', '~> 0.6.0') s.add_dependency('mime-types') + s.add_dependency('mimemagic', '~> 0.3.0') + s.add_development_dependency('activerecord', '>= 4.2.0') s.add_development_dependency('shoulda') - s.add_development_dependency('appraisal', '~> 0.4.0') + s.add_development_dependency('rspec', '~> 3.0') + s.add_development_dependency('appraisal') s.add_development_dependency('mocha') - s.add_development_dependency('aws-sdk') - s.add_development_dependency('sqlite3', '~> 1.3.4') - s.add_development_dependency('cucumber', '~> 1.1.0') - s.add_development_dependency('aruba') + s.add_development_dependency('aws-sdk-s3') + s.add_development_dependency('bourne') + s.add_development_dependency('cucumber-rails') + s.add_development_dependency('cucumber-expressions', '4.0.3') # TODO: investigate failures on 4.0.4 + s.add_development_dependency('aruba', '~> 0.9.0') + s.add_development_dependency('nokogiri') s.add_development_dependency('capybara') s.add_development_dependency('bundler') - s.add_development_dependency('cocaine', '~> 0.2') - s.add_development_dependency('fog') + s.add_development_dependency('fog-aws') + s.add_development_dependency('fog-local') + s.add_development_dependency('launchy') s.add_development_dependency('rake') s.add_development_dependency('fakeweb') + s.add_development_dependency('railties') + s.add_development_dependency('generator_spec') + s.add_development_dependency('timecop') end diff --git a/rails/init.rb b/rails/init.rb deleted file mode 100644 index 187563408..000000000 --- a/rails/init.rb +++ /dev/null @@ -1,2 +0,0 @@ -require 'paperclip/railtie' -Paperclip::Railtie.insert diff --git a/shoulda_macros/paperclip.rb b/shoulda_macros/paperclip.rb index 899f85639..aa09f9f4b 100644 --- a/shoulda_macros/paperclip.rb +++ b/shoulda_macros/paperclip.rb @@ -103,7 +103,11 @@ def paperclip_fixture(model, attachment, extension) end end -if defined?(ActionController::Integration::Session) +if defined?(ActionDispatch::Integration::Session) + class ActionDispatch::IntegrationTest::Session #:nodoc: + include Paperclip::Shoulda + end +elsif defined?(ActionController::Integration::Session) class ActionController::Integration::Session #:nodoc: include Paperclip::Shoulda end @@ -119,6 +123,12 @@ class Factory end end -class Test::Unit::TestCase #:nodoc: - extend Paperclip::Shoulda +if defined?(Minitest) + class Minitest::Unit::TestCase #:nodoc: + extend Paperclip::Shoulda + end +elsif defined?(Test) + class Test::Unit::TestCase #:nodoc: + extend Paperclip::Shoulda + end end diff --git a/test/database.yml b/spec/database.yml similarity index 100% rename from test/database.yml rename to spec/database.yml diff --git a/spec/paperclip/attachment_definitions_spec.rb b/spec/paperclip/attachment_definitions_spec.rb new file mode 100644 index 000000000..abfa9006f --- /dev/null +++ b/spec/paperclip/attachment_definitions_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe "Attachment Definitions" do + it 'returns all of the attachments on the class' do + reset_class "Dummy" + Dummy.has_attached_file :avatar, {path: "abc"} + Dummy.has_attached_file :other_attachment, {url: "123"} + Dummy.do_not_validate_attachment_file_type :avatar + expected = {avatar: {path: "abc"}, other_attachment: {url: "123"}} + + expect(Dummy.attachment_definitions).to eq expected + end +end diff --git a/spec/paperclip/attachment_processing_spec.rb b/spec/paperclip/attachment_processing_spec.rb new file mode 100644 index 000000000..f0231440f --- /dev/null +++ b/spec/paperclip/attachment_processing_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe 'Attachment Processing' do + before { rebuild_class } + + context 'using validates_attachment_content_type' do + it 'processes attachments given a valid assignment' do + file = File.new(fixture_file("5k.png")) + Dummy.validates_attachment_content_type :avatar, content_type: "image/png" + instance = Dummy.new + attachment = instance.avatar + attachment.expects(:post_process_styles) + + attachment.assign(file) + end + + it 'does not process attachments given an invalid assignment with :not' do + file = File.new(fixture_file("5k.png")) + Dummy.validates_attachment_content_type :avatar, not: "image/png" + instance = Dummy.new + attachment = instance.avatar + attachment.expects(:post_process_styles).never + + attachment.assign(file) + end + + it 'does not process attachments given an invalid assignment with :content_type' do + file = File.new(fixture_file("5k.png")) + Dummy.validates_attachment_content_type :avatar, content_type: "image/tiff" + instance = Dummy.new + attachment = instance.avatar + attachment.expects(:post_process_styles).never + + attachment.assign(file) + end + + it 'allows what would be an invalid assignment when validation :if clause returns false' do + invalid_assignment = File.new(fixture_file("5k.png")) + Dummy.validates_attachment_content_type :avatar, content_type: "image/tiff", if: lambda{false} + instance = Dummy.new + attachment = instance.avatar + attachment.expects(:post_process_styles) + + attachment.assign(invalid_assignment) + end + end + + context 'using validates_attachment' do + it 'processes attachments given a valid assignment' do + file = File.new(fixture_file("5k.png")) + Dummy.validates_attachment :avatar, content_type: {content_type: "image/png"} + instance = Dummy.new + attachment = instance.avatar + attachment.expects(:post_process_styles) + + attachment.assign(file) + end + + it 'does not process attachments given an invalid assignment with :not' do + file = File.new(fixture_file("5k.png")) + Dummy.validates_attachment :avatar, content_type: {not: "image/png"} + instance = Dummy.new + attachment = instance.avatar + attachment.expects(:post_process_styles).never + + attachment.assign(file) + end + + it 'does not process attachments given an invalid assignment with :content_type' do + file = File.new(fixture_file("5k.png")) + Dummy.validates_attachment :avatar, content_type: {content_type: "image/tiff"} + instance = Dummy.new + attachment = instance.avatar + attachment.expects(:post_process_styles).never + + attachment.assign(file) + end + end +end diff --git a/spec/paperclip/attachment_registry_spec.rb b/spec/paperclip/attachment_registry_spec.rb new file mode 100644 index 000000000..db952037a --- /dev/null +++ b/spec/paperclip/attachment_registry_spec.rb @@ -0,0 +1,158 @@ +require 'spec_helper' + +describe 'Attachment Registry' do + before do + Paperclip::AttachmentRegistry.clear + end + + context '.names_for' do + it 'includes attachment names for the given class' do + foo = Class.new + Paperclip::AttachmentRegistry.register(foo, :avatar, {}) + + assert_equal [:avatar], Paperclip::AttachmentRegistry.names_for(foo) + end + + it 'does not include attachment names for other classes' do + foo = Class.new + bar = Class.new + Paperclip::AttachmentRegistry.register(foo, :avatar, {}) + Paperclip::AttachmentRegistry.register(bar, :lover, {}) + + assert_equal [:lover], Paperclip::AttachmentRegistry.names_for(bar) + end + + it 'produces the empty array for a missing key' do + assert_empty Paperclip::AttachmentRegistry.names_for(Class.new) + end + end + + context '.each_definition' do + it 'calls the block with the class, attachment name, and options' do + foo = Class.new + expected_accumulations = [ + [foo, :avatar, { yo: "greeting" }], + [foo, :greeter, { ciao: "greeting" }] + ] + expected_accumulations.each do |args| + Paperclip::AttachmentRegistry.register(*args) + end + accumulations = [] + + Paperclip::AttachmentRegistry.each_definition do |*args| + accumulations << args + end + + assert_equal expected_accumulations, accumulations + end + end + + context '.definitions_for' do + it 'produces the attachment name and options' do + expected_definitions = { + avatar: { yo: "greeting" }, + greeter: { ciao: "greeting" } + } + foo = Class.new + Paperclip::AttachmentRegistry.register( + foo, + :avatar, + yo: "greeting" + ) + Paperclip::AttachmentRegistry.register( + foo, + :greeter, + ciao: "greeting" + ) + + definitions = Paperclip::AttachmentRegistry.definitions_for(foo) + + assert_equal expected_definitions, definitions + end + + it 'produces defintions for subclasses' do + expected_definitions = { avatar: { yo: "greeting" } } + foo = Class.new + bar = Class.new(foo) + Paperclip::AttachmentRegistry.register( + foo, + :avatar, + expected_definitions[:avatar] + ) + + definitions = Paperclip::AttachmentRegistry.definitions_for(bar) + + assert_equal expected_definitions, definitions + end + + it 'produces defintions for subclasses but deep merging them' do + foo_definitions = { avatar: { yo: "greeting" } } + bar_definitions = { avatar: { ciao: "greeting" } } + expected_definitions = { + avatar: { + yo: "greeting", + ciao: "greeting" + } + } + foo = Class.new + bar = Class.new(foo) + Paperclip::AttachmentRegistry.register( + foo, + :avatar, + foo_definitions[:avatar] + ) + Paperclip::AttachmentRegistry.register( + bar, + :avatar, + bar_definitions[:avatar] + ) + + definitions = Paperclip::AttachmentRegistry.definitions_for(bar) + + assert_equal expected_definitions, definitions + end + + it 'allows subclasses to override attachment defitions' do + foo_definitions = { avatar: { yo: "greeting" } } + bar_definitions = { avatar: { yo: "hello" } } + + expected_definitions = { + avatar: { + yo: "hello" + } + } + + foo = Class.new + bar = Class.new(foo) + Paperclip::AttachmentRegistry.register( + foo, + :avatar, + foo_definitions[:avatar] + ) + Paperclip::AttachmentRegistry.register( + bar, + :avatar, + bar_definitions[:avatar] + ) + + definitions = Paperclip::AttachmentRegistry.definitions_for(bar) + + assert_equal expected_definitions, definitions + end + end + + context '.clear' do + it 'removes all of the existing attachment definitions' do + foo = Class.new + Paperclip::AttachmentRegistry.register( + foo, + :greeter, + ciao: "greeting" + ) + + Paperclip::AttachmentRegistry.clear + + assert_empty Paperclip::AttachmentRegistry.names_for(foo) + end + end +end diff --git a/spec/paperclip/attachment_spec.rb b/spec/paperclip/attachment_spec.rb new file mode 100644 index 000000000..7381ba5aa --- /dev/null +++ b/spec/paperclip/attachment_spec.rb @@ -0,0 +1,1563 @@ +require 'spec_helper' + +describe Paperclip::Attachment do + + it "is not present when file not set" do + rebuild_class + dummy = Dummy.new + expect(dummy.avatar).to be_blank + expect(dummy.avatar).to_not be_present + end + + it "is present when the file is set" do + rebuild_class + dummy = Dummy.new + dummy.avatar = File.new(fixture_file("50x50.png"), "rb") + expect(dummy.avatar).to_not be_blank + expect(dummy.avatar).to be_present + end + + it "processes :original style first" do + file = File.new(fixture_file("50x50.png"), 'rb') + rebuild_class styles: { small: '100x>', original: '42x42#' } + dummy = Dummy.new + dummy.avatar = file + dummy.save + + # :small avatar should be 42px wide (processed original), not 50px (preprocessed original) + expect(`identify -format "%w" "#{dummy.avatar.path(:small)}"`.strip).to eq "42" + + file.close + end + + it "does not delete styles that don't get reprocessed" do + file = File.new(fixture_file("50x50.png"), 'rb') + rebuild_class styles: { + small: "100x>", + large: "500x>", + original: "42x42#" + } + + dummy = Dummy.new + dummy.avatar = file + dummy.save + + expect(dummy.avatar.path(:small)).to exist + expect(dummy.avatar.path(:large)).to exist + expect(dummy.avatar.path(:original)).to exist + + dummy.avatar.reprocess!(:small) + + expect(dummy.avatar.path(:small)).to exist + expect(dummy.avatar.path(:large)).to exist + expect(dummy.avatar.path(:original)).to exist + end + + it "reprocess works with virtual content_type attribute" do + rebuild_class styles: { small: "100x>" } + modify_table { |t| t.remove :avatar_content_type } + Dummy.send :attr_accessor, :avatar_content_type + Dummy.validates_attachment_content_type( + :avatar, + content_type: %w(image/jpeg image/png) + ) + Dummy.create!(avatar: File.new(fixture_file("50x50.png"), "rb")) + + dummy = Dummy.first + dummy.avatar.reprocess!(:small) + + expect(dummy.avatar.path(:small)).to exist + end + + context "having a not empty hash as a default option" do + before do + @old_default_options = Paperclip::Attachment.default_options.dup + @new_default_options = { convert_options: { all: "-background white" } } + Paperclip::Attachment.default_options.merge!(@new_default_options) + end + + after do + Paperclip::Attachment.default_options.merge!(@old_default_options) + end + + it "deep merges when it is overridden" do + new_options = { convert_options: { thumb: "-thumbnailize" } } + attachment = Paperclip::Attachment.new(:name, :instance, new_options) + + expect(Paperclip::Attachment.default_options.deep_merge(new_options)).to eq attachment.instance_variable_get("@options") + end + end + + it "handles a boolean second argument to #url" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new( + :name, + FakeModel.new, + url_generator: mock_url_generator_builder + ) + + attachment.url(:style_name, true) + expect(mock_url_generator_builder.has_generated_url_with_options?(timestamp: true, escape: true)).to eq true + + attachment.url(:style_name, false) + expect(mock_url_generator_builder.has_generated_url_with_options?(timestamp: false, escape: true)).to eq true + end + + it "passes the style and options through to the URL generator on #url" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new( + :name, + FakeModel.new, + url_generator: mock_url_generator_builder + ) + + attachment.url(:style_name, options: :values) + expect(mock_url_generator_builder.has_generated_url_with_options?(options: :values)).to eq true + end + + it "passes default options through when #url is given one argument" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + url_generator: mock_url_generator_builder, + use_timestamp: true) + + attachment.url(:style_name) + assert mock_url_generator_builder.has_generated_url_with_options?(escape: true, timestamp: true) + end + + it "passes default style and options through when #url is given no arguments" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + default_style: 'default style', + url_generator: mock_url_generator_builder, + use_timestamp: true) + + attachment.url + assert mock_url_generator_builder.has_generated_url_with_options?(escape: true, timestamp: true) + assert mock_url_generator_builder.has_generated_url_with_style_name?('default style') + end + + it "passes the option timestamp: true if :use_timestamp is true and :timestamp is not passed" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + url_generator: mock_url_generator_builder, + use_timestamp: true) + + attachment.url(:style_name) + assert mock_url_generator_builder.has_generated_url_with_options?(escape: true, timestamp: true) + end + + it "passes the option timestamp: false if :use_timestamp is false and :timestamp is not passed" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + url_generator: mock_url_generator_builder, + use_timestamp: false) + + attachment.url(:style_name) + assert mock_url_generator_builder.has_generated_url_with_options?(escape: true, timestamp: false) + end + + it "does not change the :timestamp if :timestamp is passed" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + url_generator: mock_url_generator_builder, + use_timestamp: false) + + attachment.url(:style_name, timestamp: true) + assert mock_url_generator_builder.has_generated_url_with_options?(escape: true, timestamp: true) + end + + it "renders JSON as default style" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + default_style: 'default style', + url_generator: mock_url_generator_builder) + + attachment.as_json + assert mock_url_generator_builder.has_generated_url_with_style_name?('default style') + end + + it "passes the option escape: true if :escape_url is true and :escape is not passed" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + url_generator: mock_url_generator_builder, + escape_url: true) + + attachment.url(:style_name) + assert mock_url_generator_builder.has_generated_url_with_options?(escape: true) + end + + it "passes the option escape: false if :escape_url is false and :escape is not passed" do + mock_url_generator_builder = MockUrlGeneratorBuilder.new + attachment = Paperclip::Attachment.new(:name, + FakeModel.new, + url_generator: mock_url_generator_builder, + escape_url: false) + + attachment.url(:style_name) + assert mock_url_generator_builder.has_generated_url_with_options?(escape: false) + end + + it "returns the path based on the url by default" do + @attachment = attachment url: "/:class/:id/:basename" + @model = @attachment.instance + @model.id = 1234 + @model.avatar_file_name = "fake.jpg" + assert_equal "#{Rails.root}/public/fake_models/1234/fake", @attachment.path + end + + it "defaults to a path that scales" do + avatar_attachment = attachment + model = avatar_attachment.instance + model.id = 1234 + model.avatar_file_name = "fake.jpg" + expected_path = "#{Rails.root}/public/system/fake_models/avatars/000/001/234/original/fake.jpg" + assert_equal expected_path, avatar_attachment.path + end + + it "renders JSON as the URL to the attachment" do + avatar_attachment = attachment + model = avatar_attachment.instance + model.id = 1234 + model.avatar_file_name = "fake.jpg" + assert_equal attachment.url, attachment.as_json + end + + it "renders JSON from the model when requested by :methods" do + rebuild_model + dummy = Dummy.new + dummy.id = 1234 + dummy.avatar_file_name = "fake.jpg" + dummy.stubs(:new_record?).returns(false) + expected_string = '{"avatar":"/system/dummies/avatars/000/001/234/original/fake.jpg"}' + # active_model pre-3.2 checks only by calling any? on it, thus it doesn't work if it is empty + assert_equal expected_string, dummy.to_json(only: [:dummy_key_for_old_active_model], methods: [:avatar]) + end + + context "Attachment default_options" do + before do + rebuild_model + @old_default_options = Paperclip::Attachment.default_options.dup + @new_default_options = @old_default_options.merge({ + path: "argle/bargle", + url: "fooferon", + default_url: "not here.png" + }) + end + + after do + Paperclip::Attachment.default_options.merge! @old_default_options + end + + it "is overrideable" do + Paperclip::Attachment.default_options.merge!(@new_default_options) + @new_default_options.keys.each do |key| + assert_equal @new_default_options[key], + Paperclip::Attachment.default_options[key] + end + end + + context "without an Attachment" do + before do + rebuild_model default_url: "default.url" + @dummy = Dummy.new + end + + it "returns false when asked exists?" do + assert !@dummy.avatar.exists? + end + + it "#url returns the default_url" do + expect(@dummy.avatar.url).to eq "default.url" + end + end + + context "on an Attachment" do + before do + @dummy = Dummy.new + @attachment = @dummy.avatar + end + + Paperclip::Attachment.default_options.keys.each do |key| + it "is the default_options for #{key}" do + assert_equal @old_default_options[key], + @attachment.instance_variable_get("@options")[key], + key.to_s + end + end + + context "when redefined" do + before do + Paperclip::Attachment.default_options.merge!(@new_default_options) + @dummy = Dummy.new + @attachment = @dummy.avatar + end + + Paperclip::Attachment.default_options.keys.each do |key| + it "is the new default_options for #{key}" do + assert_equal @new_default_options[key], + @attachment.instance_variable_get("@options")[key], + key.to_s + end + end + end + end + end + + context "An attachment with similarly named interpolations" do + before do + rebuild_model path: ":id.omg/:id-bbq/:idwhat/:id_partition.wtf" + @dummy = Dummy.new + @dummy.stubs(:id).returns(1024) + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy.avatar = @file + end + + after { @file.close } + + it "makes sure that they are interpolated correctly" do + assert_equal "1024.omg/1024-bbq/1024what/000/001/024.wtf", @dummy.avatar.path + end + end + + context "An attachment with :timestamp interpolations" do + before do + @file = StringIO.new("...") + @zone = 'UTC' + Time.stubs(:zone).returns(@zone) + @zone_default = 'Eastern Time (US & Canada)' + Time.stubs(:zone_default).returns(@zone_default) + end + + context "using default time zone" do + before do + rebuild_model path: ":timestamp", use_default_time_zone: true + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "returns a time in the default zone" do + assert_equal @dummy.avatar_updated_at.in_time_zone(@zone_default).to_s, @dummy.avatar.path + end + end + + context "using per-thread time zone" do + before do + rebuild_model path: ":timestamp", use_default_time_zone: false + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "returns a time in the per-thread zone" do + assert_equal @dummy.avatar_updated_at.in_time_zone(@zone).to_s, @dummy.avatar.path + end + end + end + + context "An attachment with :hash interpolations" do + before do + @file = File.open(fixture_file("5k.png")) + end + + after do + @file.close + end + + it "raises if no secret is provided" do + rebuild_model path: ":hash" + @attachment = Dummy.new.avatar + @attachment.assign @file + + assert_raises ArgumentError do + @attachment.path + end + end + + context "when secret is set" do + before do + rebuild_model path: ":hash", + hash_secret: "w00t", + hash_data: ":class/:attachment/:style/:filename" + @attachment = Dummy.new.avatar + @attachment.assign @file + end + + it "results in the correct interpolation" do + assert_equal "dummies/avatars/original/5k.png", + @attachment.send(:interpolate, @attachment.options[:hash_data]) + assert_equal "dummies/avatars/thumb/5k.png", + @attachment.send(:interpolate, @attachment.options[:hash_data], :thumb) + end + + it "results in a correct hash" do + assert_equal "0a59e9142bba11576de1d353d8747b1acad5ad34", @attachment.path + assert_equal "b39a062c1e62e85a6c785ed00cf3bebf5f850e2b", @attachment.path(:thumb) + end + end + end + + context "An attachment with a :rails_env interpolation" do + before do + @rails_env = "blah" + @id = 1024 + rebuild_model path: ":rails_env/:id.png" + @dummy = Dummy.new + @dummy.stubs(:id).returns(@id) + @file = StringIO.new(".") + @dummy.avatar = @file + Rails.stubs(:env).returns(@rails_env) + end + + it "returns the proper path" do + assert_equal "#{@rails_env}/#{@id}.png", @dummy.avatar.path + end + end + + context "An attachment with a default style and an extension interpolation" do + before do + rebuild_model path: ":basename.:extension", + styles: { default: ["100x100", :jpg] }, + default_style: :default + @attachment = Dummy.new.avatar + @file = File.open(fixture_file("5k.png")) + @file.stubs(:original_filename).returns("file.png") + end + it "returns the right extension for the path" do + @attachment.assign(@file) + assert_equal "file.jpg", @attachment.path + end + end + + context "An attachment with :convert_options" do + before do + rebuild_model styles: { + thumb: "100x100", + large: "400x400" + }, + convert_options: { + all: "-do_stuff", + thumb: "-thumbnailize" + } + @dummy = Dummy.new + @dummy.avatar + end + + it "reports the correct options when sent #extra_options_for(:thumb)" do + assert_equal "-thumbnailize -do_stuff", @dummy.avatar.send(:extra_options_for, :thumb), @dummy.avatar.convert_options.inspect + end + + it "reports the correct options when sent #extra_options_for(:large)" do + assert_equal "-do_stuff", @dummy.avatar.send(:extra_options_for, :large) + end + end + + context "An attachment with :source_file_options" do + before do + rebuild_model styles: { + thumb: "100x100", + large: "400x400" + }, + source_file_options: { + all: "-density 400", + thumb: "-depth 8" + } + @dummy = Dummy.new + @dummy.avatar + end + + it "reports the correct options when sent #extra_source_file_options_for(:thumb)" do + assert_equal "-depth 8 -density 400", @dummy.avatar.send(:extra_source_file_options_for, :thumb), @dummy.avatar.source_file_options.inspect + end + + it "reports the correct options when sent #extra_source_file_options_for(:large)" do + assert_equal "-density 400", @dummy.avatar.send(:extra_source_file_options_for, :large) + end + end + + context "An attachment with :only_process" do + before do + rebuild_model styles: { + thumb: "100x100", + large: "400x400" + }, + only_process: [:thumb] + @file = StringIO.new("...") + @attachment = Dummy.new.avatar + end + + it "only processes the provided style" do + @attachment.expects(:post_process).with(:thumb) + @attachment.expects(:post_process).with(:large).never + @attachment.assign(@file) + end + end + + context "An attachment with :only_process that is a proc" do + before do + rebuild_model styles: { + thumb: "100x100", + large: "400x400" + }, + only_process: lambda { |attachment| [:thumb] } + + @file = StringIO.new("...") + @attachment = Dummy.new.avatar + end + + it "only processes the provided style" do + @attachment.expects(:post_process).with(:thumb) + @attachment.expects(:post_process).with(:large).never + @attachment.assign(@file) + @attachment.save + end + end + + context "An attachment with :convert_options that is a proc" do + before do + rebuild_model styles: { + thumb: "100x100", + large: "400x400" + }, + convert_options: { + all: lambda{|i| i.all }, + thumb: lambda{|i| i.thumb } + } + Dummy.class_eval do + def all; "-all"; end + def thumb; "-thumb"; end + end + @dummy = Dummy.new + @dummy.avatar + end + + it "reports the correct options when sent #extra_options_for(:thumb)" do + assert_equal "-thumb -all", @dummy.avatar.send(:extra_options_for, :thumb), @dummy.avatar.convert_options.inspect + end + + it "reports the correct options when sent #extra_options_for(:large)" do + assert_equal "-all", @dummy.avatar.send(:extra_options_for, :large) + end + end + + context "An attachment with :path that is a proc" do + before do + rebuild_model path: lambda{ |attachment| "path/#{attachment.instance.other}.:extension" } + + @file = File.new(fixture_file("5k.png"), 'rb') + @dummyA = Dummy.new(other: 'a') + @dummyA.avatar = @file + @dummyB = Dummy.new(other: 'b') + @dummyB.avatar = @file + end + + after { @file.close } + + it "returns correct path" do + assert_equal "path/a.png", @dummyA.avatar.path + assert_equal "path/b.png", @dummyB.avatar.path + end + end + + context "An attachment with :styles that is a proc" do + before do + rebuild_model styles: lambda{ |attachment| {thumb: "50x50#", large: "400x400"} } + + @attachment = Dummy.new.avatar + end + + it "has the correct geometry" do + assert_equal "50x50#", @attachment.styles[:thumb][:geometry] + end + end + + context "An attachment with conditional :styles that is a proc" do + before do + rebuild_model styles: lambda{ |attachment| attachment.instance.other == 'a' ? {thumb: "50x50#"} : {large: "400x400"} } + + @dummy = Dummy.new(other: 'a') + end + + it "has the correct styles for the assigned instance values" do + assert_equal "50x50#", @dummy.avatar.styles[:thumb][:geometry] + assert_nil @dummy.avatar.styles[:large] + + @dummy.other = 'b' + + assert_equal "400x400", @dummy.avatar.styles[:large][:geometry] + assert_nil @dummy.avatar.styles[:thumb] + end + end + + geometry_specs = [ + [ lambda{|z| "50x50#" }, :png ], + lambda{|z| "50x50#" }, + { geometry: lambda{|z| "50x50#" } } + ] + geometry_specs.each do |geometry_spec| + context "An attachment geometry like #{geometry_spec}" do + before do + rebuild_model styles: { normal: geometry_spec } + @attachment = Dummy.new.avatar + end + + context "when assigned" do + before do + @file = StringIO.new(".") + @attachment.assign(@file) + end + + it "has the correct geometry" do + assert_equal "50x50#", @attachment.styles[:normal][:geometry] + end + end + end + end + + context "An attachment with both 'normal' and hash-style styles" do + before do + rebuild_model styles: { + normal: ["50x50#", :png], + hash: { geometry: "50x50#", format: :png } + } + @dummy = Dummy.new + @attachment = @dummy.avatar + end + + [:processors, :whiny, :convert_options, :geometry, :format].each do |field| + it "has the same #{field} field" do + assert_equal @attachment.styles[:normal][field], @attachment.styles[:hash][field] + end + end + end + + context "An attachment with :processors that is a proc" do + before do + class Paperclip::Test < Paperclip::Processor; end + @file = StringIO.new("...") + Paperclip::Test.stubs(:make).returns(@file) + + rebuild_model styles: { normal: '' }, processors: lambda { |a| [ :test ] } + @attachment = Dummy.new.avatar + end + + context "when assigned" do + before do + @attachment.assign(StringIO.new(".")) + end + + it "has the correct processors" do + assert_equal [ :test ], @attachment.styles[:normal][:processors] + end + end + end + + context "An attachment with erroring processor" do + before do + rebuild_model processor: [:thumbnail], styles: { small: '' }, whiny_thumbnails: true + @dummy = Dummy.new + @file = StringIO.new("...") + @file.stubs(:to_tempfile).returns(@file) + end + + context "when error is meaningful for the end user" do + before do + Paperclip::Thumbnail.expects(:make).raises( + Paperclip::Errors::NotIdentifiedByImageMagickError, + "cannot be processed." + ) + end + + it "correctly forwards processing error message to the instance" do + @dummy.avatar = @file + @dummy.valid? + assert_contains( + @dummy.errors.full_messages, + "Avatar cannot be processed." + ) + end + end + + context "when error is intended for the developer" do + before do + Paperclip::Thumbnail.expects(:make).raises( + Paperclip::Errors::CommandNotFoundError + ) + end + + it "propagates the error" do + assert_raises(Paperclip::Errors::CommandNotFoundError) do + @dummy.avatar = @file + end + end + end + end + + context "An attachment with multiple processors" do + before do + class Paperclip::Test < Paperclip::Processor; end + @style_params = { once: {one: 1, two: 2} } + rebuild_model processors: [:thumbnail, :test], styles: @style_params + @dummy = Dummy.new + @file = StringIO.new("...") + @file.stubs(:close) + Paperclip::Test.stubs(:make).returns(@file) + Paperclip::Thumbnail.stubs(:make).returns(@file) + end + + context "when assigned" do + it "calls #make on all specified processors" do + @dummy.avatar = @file + + expect(Paperclip::Thumbnail).to have_received(:make) + expect(Paperclip::Test).to have_received(:make) + end + + it "calls #make with the right parameters passed as second argument" do + expected_params = @style_params[:once].merge({ + style: :once, + processors: [:thumbnail, :test], + whiny: true, + convert_options: "", + source_file_options: "" + }) + + @dummy.avatar = @file + + expect(Paperclip::Thumbnail).to have_received(:make).with(anything, expected_params, anything) + end + + it "calls #make with attachment passed as third argument" do + @dummy.avatar = @file + + expect(Paperclip::Test).to have_received(:make).with(anything, anything, @dummy.avatar) + end + + it "calls #make and unlinks intermediary files afterward" do + @dummy.avatar.expects(:unlink_files).with([@file, @file]) + + @dummy.avatar = @file + end + end + end + + context "An attachment with a processor that returns original file" do + before do + class Paperclip::Test < Paperclip::Processor + def make; @file; end + end + rebuild_model processors: [:test], styles: { once: "100x100" } + @file = StringIO.new("...") + @file.stubs(:close) + @dummy = Dummy.new + end + + context "when assigned" do + it "#calls #make and doesn't unlink the original file" do + @dummy.avatar.expects(:unlink_files).with([]) + + @dummy.avatar = @file + end + end + end + + it "includes the filesystem module when loading the filesystem storage" do + rebuild_model storage: :filesystem + @dummy = Dummy.new + assert @dummy.avatar.is_a?(Paperclip::Storage::Filesystem) + end + + it "includes the filesystem module even if capitalization is wrong" do + rebuild_model storage: :FileSystem + @dummy = Dummy.new + assert @dummy.avatar.is_a?(Paperclip::Storage::Filesystem) + + rebuild_model storage: :Filesystem + @dummy = Dummy.new + assert @dummy.avatar.is_a?(Paperclip::Storage::Filesystem) + end + + it "converts underscored storage name to camelcase" do + rebuild_model storage: :not_here + @dummy = Dummy.new + exception = assert_raises(Paperclip::Errors::StorageMethodNotFound, /NotHere/) do + @dummy.avatar + end + end + + it "raises an error if you try to include a storage module that doesn't exist" do + rebuild_model storage: :not_here + @dummy = Dummy.new + assert_raises(Paperclip::Errors::StorageMethodNotFound) do + @dummy.avatar + end + end + + context "An attachment with styles but no processors defined" do + before do + rebuild_model processors: [], styles: {something: '1'} + @dummy = Dummy.new + @file = StringIO.new("...") + end + it "raises when assigned to" do + assert_raises(RuntimeError){ @dummy.avatar = @file } + end + end + + context "An attachment without styles and with no processors defined" do + before do + rebuild_model processors: [], styles: {} + @dummy = Dummy.new + @file = StringIO.new("...") + end + it "does not raise when assigned to" do + @dummy.avatar = @file + end + end + + context "Assigning an attachment with post_process hooks" do + before do + rebuild_class styles: { something: "100x100#" } + Dummy.class_eval do + before_avatar_post_process :do_before_avatar + after_avatar_post_process :do_after_avatar + before_post_process :do_before_all + after_post_process :do_after_all + def do_before_avatar; end + def do_after_avatar; end + def do_before_all; end + def do_after_all; end + end + @file = StringIO.new(".") + @file.stubs(:to_tempfile).returns(@file) + @dummy = Dummy.new + Paperclip::Thumbnail.stubs(:make).returns(@file) + @attachment = @dummy.avatar + end + + it "calls the defined callbacks when assigned" do + @dummy.expects(:do_before_avatar).with() + @dummy.expects(:do_after_avatar).with() + @dummy.expects(:do_before_all).with() + @dummy.expects(:do_after_all).with() + Paperclip::Thumbnail.expects(:make).returns(@file) + @dummy.avatar = @file + end + + it "does not cancel the processing if a before_post_process returns nil" do + @dummy.expects(:do_before_avatar).with().returns(nil) + @dummy.expects(:do_after_avatar).with() + @dummy.expects(:do_before_all).with().returns(nil) + @dummy.expects(:do_after_all).with() + Paperclip::Thumbnail.expects(:make).returns(@file) + @dummy.avatar = @file + end + + it "cancels the processing if a before_post_process returns false" do + @dummy.expects(:do_before_avatar).never + @dummy.expects(:do_after_avatar).never + @dummy.expects(:do_before_all).with().returns(false) + @dummy.expects(:do_after_all) + Paperclip::Thumbnail.expects(:make).never + @dummy.avatar = @file + end + + it "cancels the processing if a before_avatar_post_process returns false" do + @dummy.expects(:do_before_avatar).with().returns(false) + @dummy.expects(:do_after_avatar) + @dummy.expects(:do_before_all).with().returns(true) + @dummy.expects(:do_after_all) + Paperclip::Thumbnail.expects(:make).never + @dummy.avatar = @file + end + end + + context "Assigning an attachment" do + before do + rebuild_model styles: { something: "100x100#" } + @file = File.new(fixture_file("5k.png"), "rb") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "strips whitespace from original_filename field" do + assert_equal "5k.png", @dummy.avatar.original_filename + end + + it "strips whitespace from content_type field" do + assert_equal "image/png", @dummy.avatar.instance.avatar_content_type + end + end + + context "Assigning an attachment" do + before do + rebuild_model styles: { something: "100x100#" } + @file = File.new(fixture_file("5k.png"), "rb") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "makes sure the content_type is a string" do + assert_equal "image/png", @dummy.avatar.instance.avatar_content_type + end + end + + context "Attachment with strange letters" do + before do + rebuild_model + @file = File.new(fixture_file("5k.png"), "rb") + @file.stubs(:original_filename).returns("sheep_say_bæ.png") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "does not remove strange letters" do + assert_equal "sheep_say_bæ.png", @dummy.avatar.original_filename + end + end + + context "Attachment with reserved filename" do + before do + rebuild_model + @file = Tempfile.new(["filename","png"]) + end + + after do + @file.unlink + end + + context "with default configuration" do + "&$+,/:;=?@<>[]{}|\^~%# ".split(//).each do |character| + context "with character #{character}" do + + context "at beginning of filename" do + before do + @file.stubs(:original_filename).returns("#{character}filename.png") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "converts special character into underscore" do + assert_equal "_filename.png", @dummy.avatar.original_filename + end + end + + context "at end of filename" do + before do + @file.stubs(:original_filename).returns("filename.png#{character}") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "converts special character into underscore" do + assert_equal "filename.png_", @dummy.avatar.original_filename + end + end + + context "in the middle of filename" do + before do + @file.stubs(:original_filename).returns("file#{character}name.png") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "converts special character into underscore" do + assert_equal "file_name.png", @dummy.avatar.original_filename + end + end + + end + end + end + + context "with specified regexp replacement" do + before do + @old_defaults = Paperclip::Attachment.default_options.dup + end + + after do + Paperclip::Attachment.default_options.merge! @old_defaults + end + + context 'as another regexp' do + before do + Paperclip::Attachment.default_options.merge! restricted_characters: /o/ + + @file.stubs(:original_filename).returns("goood.png") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "matches and converts that character" do + assert_equal "g___d.png", @dummy.avatar.original_filename + end + end + + context 'as nil' do + before do + Paperclip::Attachment.default_options.merge! restricted_characters: nil + + @file.stubs(:original_filename).returns("goood.png") + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "ignores and returns the original file name" do + assert_equal "goood.png", @dummy.avatar.original_filename + end + end + end + + context 'with specified cleaner' do + before do + @old_defaults = Paperclip::Attachment.default_options.dup + end + + after do + Paperclip::Attachment.default_options.merge! @old_defaults + end + + it 'calls the given proc and take the result as cleaned filename' do + Paperclip::Attachment.default_options[:filename_cleaner] = lambda do |str| + "from_proc_#{str}" + end + + @file.stubs(:original_filename).returns("goood.png") + @dummy = Dummy.new + @dummy.avatar = @file + assert_equal "from_proc_goood.png", @dummy.avatar.original_filename + end + + it 'calls the given object and take the result as the cleaned filename' do + class MyCleaner + def call(filename) + "foo" + end + end + Paperclip::Attachment.default_options[:filename_cleaner] = MyCleaner.new + + @file.stubs(:original_filename).returns("goood.png") + @dummy = Dummy.new + @dummy.avatar = @file + assert_equal "foo", @dummy.avatar.original_filename + end + end + end + + context "Attachment with uppercase extension and a default style" do + before do + @old_defaults = Paperclip::Attachment.default_options.dup + Paperclip::Attachment.default_options.merge!({ + path: ":rails_root/:attachment/:class/:style/:id/:basename.:extension" + }) + FileUtils.rm_rf("tmp") + rebuild_model styles: { large: ["400x400", :jpg], + medium: ["100x100", :jpg], + small: ["32x32#", :jpg]}, + default_style: :small + @instance = Dummy.new + @instance.stubs(:id).returns 123 + @file = File.new(fixture_file("uppercase.PNG"), 'rb') + + @attachment = @instance.avatar + + now = Time.now + Time.stubs(:now).returns(now) + @attachment.assign(@file) + @attachment.save + end + + after do + @file.close + Paperclip::Attachment.default_options.merge!(@old_defaults) + end + + it "has matching to_s and url methods" do + assert_equal @attachment.to_s, @attachment.url + assert_equal @attachment.to_s(:small), @attachment.url(:small) + end + + it "has matching expiring_url and url methods when using the filesystem storage" do + assert_equal @attachment.expiring_url, @attachment.url + end + end + + context "An attachment" do + before do + @old_defaults = Paperclip::Attachment.default_options.dup + Paperclip::Attachment.default_options.merge!({ + path: ":rails_root/:attachment/:class/:style/:id/:basename.:extension" + }) + FileUtils.rm_rf("tmp") + rebuild_model + @instance = Dummy.new + @instance.stubs(:id).returns 123 + # @attachment = Paperclip::Attachment.new(:avatar, @instance) + @attachment = @instance.avatar + @file = File.new(fixture_file("5k.png"), 'rb') + end + + after do + @file.close + Paperclip::Attachment.default_options.merge!(@old_defaults) + end + + it "raises if there are not the correct columns when you try to assign" do + @other_attachment = Paperclip::Attachment.new(:not_here, @instance) + assert_raises(Paperclip::Error) do + @other_attachment.assign(@file) + end + end + + it 'clears out the previous assignment when assigned nil' do + @attachment.assign(@file) + @attachment.queued_for_write[:original] + @attachment.assign(nil) + assert_nil @attachment.queued_for_write[:original] + end + + it 'does not do anything when it is assigned an empty string' do + @attachment.assign(@file) + original_file = @attachment.queued_for_write[:original] + @attachment.assign("") + assert_equal original_file, @attachment.queued_for_write[:original] + end + + it "returns nil as path when no file assigned" do + assert_equal nil, @attachment.path + assert_equal nil, @attachment.path(:blah) + end + + context "with a file assigned but not saved yet" do + it "clears out any attached files" do + @attachment.assign(@file) + assert @attachment.queued_for_write.present? + @attachment.clear + assert @attachment.queued_for_write.blank? + end + end + + context "with a file assigned in the database" do + before do + @attachment.stubs(:instance_read).with(:file_name).returns("5k.png") + @attachment.stubs(:instance_read).with(:content_type).returns("image/png") + @attachment.stubs(:instance_read).with(:file_size).returns(12345) + dtnow = DateTime.now + @now = Time.now + Time.stubs(:now).returns(@now) + @attachment.stubs(:instance_read).with(:updated_at).returns(dtnow) + end + + it "returns the proper path when filename has a single .'s" do + assert_equal File.expand_path("tmp/avatars/dummies/original/#{@instance.id}/5k.png"), File.expand_path(@attachment.path) + end + + it "returns the proper path when filename has multiple .'s" do + @attachment.stubs(:instance_read).with(:file_name).returns("5k.old.png") + assert_equal File.expand_path("tmp/avatars/dummies/original/#{@instance.id}/5k.old.png"), File.expand_path(@attachment.path) + end + + context "when expecting three styles" do + before do + rebuild_class styles: { + large: ["400x400", :png], + medium: ["100x100", :gif], + small: ["32x32#", :jpg] + } + @instance = Dummy.new + @instance.stubs(:id).returns 123 + @file = File.new(fixture_file("5k.png"), 'rb') + @attachment = @instance.avatar + end + + context "and assigned a file" do + before do + now = Time.now + Time.stubs(:now).returns(now) + @attachment.assign(@file) + end + + it "is dirty" do + assert @attachment.dirty? + end + + context "and saved" do + before do + @attachment.save + end + + it "commits the files to disk" do + [:large, :medium, :small].each do |style| + expect(@attachment.path(style)).to exist + end + end + + it "saves the files as the right formats and sizes" do + [[:large, 400, 61, "PNG"], + [:medium, 100, 15, "GIF"], + [:small, 32, 32, "JPEG"]].each do |style| + cmd = %Q[identify -format "%w %h %b %m" "#{@attachment.path(style.first)}"] + out = `#{cmd}` + width, height, _size, format = out.split(" ") + assert_equal style[1].to_s, width.to_s + assert_equal style[2].to_s, height.to_s + assert_equal style[3].to_s, format.to_s + end + end + + context "and trying to delete" do + before do + @existing_names = @attachment.styles.keys.collect do |style| + @attachment.path(style) + end + end + + it "deletes the files after assigning nil" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:fingerprint, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.assign nil + @attachment.save + @existing_names.each{|f| assert_file_not_exists(f) } + end + + it "deletes the files when you call #clear and #save" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:fingerprint, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.clear + @attachment.save + @existing_names.each{|f| assert_file_not_exists(f) } + end + + it "deletes the files when you call #delete" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:fingerprint, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.destroy + @existing_names.each{|f| assert_file_not_exists(f) } + end + + context "when keeping old files" do + before do + @attachment.options[:keep_old_files] = true + end + + it "keeps the files after assigning nil" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:fingerprint, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.assign nil + @attachment.save + @existing_names.each{|f| assert_file_exists(f) } + end + + it "keeps the files when you call #clear and #save" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:fingerprint, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.clear + @attachment.save + @existing_names.each{|f| assert_file_exists(f) } + end + + it "keeps the files when you call #delete" do + @attachment.expects(:instance_write).with(:file_name, nil) + @attachment.expects(:instance_write).with(:content_type, nil) + @attachment.expects(:instance_write).with(:file_size, nil) + @attachment.expects(:instance_write).with(:fingerprint, nil) + @attachment.expects(:instance_write).with(:updated_at, nil) + @attachment.destroy + @existing_names.each{|f| assert_file_exists(f) } + end + end + end + end + end + end + end + + context "when trying a nonexistant storage type" do + before do + rebuild_model storage: :not_here + end + + it "is not able to find the module" do + assert_raises(Paperclip::Errors::StorageMethodNotFound){ Dummy.new.avatar } + end + end + end + + context "An attachment with only a avatar_file_name column" do + before do + ActiveRecord::Base.connection.create_table :dummies, force: true do |table| + table.column :avatar_file_name, :string + end + rebuild_class + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + end + + after { @file.close } + + it "does not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + it "does not return the time when sent #avatar_updated_at" do + @dummy.avatar = @file + assert_nil @dummy.avatar.updated_at + end + + it "returns the right value when sent #avatar_file_size" do + @dummy.avatar = @file + assert_equal File.size(@file), @dummy.avatar.size + end + + context "and avatar_created_at column" do + before do + ActiveRecord::Base.connection.add_column :dummies, :avatar_created_at, :timestamp + rebuild_class + @dummy = Dummy.new + end + + it "does not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + it "returns the creation time when sent #avatar_created_at" do + now = Time.now + Time.stubs(:now).returns(now) + @dummy.avatar = @file + assert_equal now.to_i, @dummy.avatar.created_at + end + + it "returns the creation time when sent #avatar_created_at and the entry has been updated" do + creation = 2.hours.ago + now = Time.now + Time.stubs(:now).returns(creation) + @dummy.avatar = @file + Time.stubs(:now).returns(now) + @dummy.avatar = @file + assert_equal creation.to_i, @dummy.avatar.created_at + assert_not_equal now.to_i, @dummy.avatar.created_at + end + + it "sets changed? to true on attachment assignment" do + @dummy.avatar = @file + @dummy.save! + @dummy.avatar = @file + assert @dummy.changed? + end + end + + context "and avatar_updated_at column" do + before do + ActiveRecord::Base.connection.add_column :dummies, :avatar_updated_at, :timestamp + rebuild_class + @dummy = Dummy.new + end + + it "does not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + it "returns the right value when sent #avatar_updated_at" do + now = Time.now + Time.stubs(:now).returns(now) + @dummy.avatar = @file + assert_equal now.to_i, @dummy.avatar.updated_at + end + end + + it "does not calculate fingerprint" do + Digest::MD5.stubs(:file) + @dummy.avatar = @file + expect(Digest::MD5).to have_received(:file).never + end + + it "does not assign fingerprint" do + @dummy.avatar = @file + assert_nil @dummy.avatar.fingerprint + end + + context "and avatar_content_type column" do + before do + ActiveRecord::Base.connection.add_column :dummies, :avatar_content_type, :string + rebuild_class + @dummy = Dummy.new + end + + it "does not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + it "returns the right value when sent #avatar_content_type" do + @dummy.avatar = @file + assert_equal "image/png", @dummy.avatar.content_type + end + end + + context "and avatar_file_size column" do + before do + ActiveRecord::Base.connection.add_column :dummies, :avatar_file_size, :integer + rebuild_class + @dummy = Dummy.new + end + + it "does not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + it "returns the right value when sent #avatar_file_size" do + @dummy.avatar = @file + assert_equal File.size(@file), @dummy.avatar.size + end + + it "returns the right value when saved, reloaded, and sent #avatar_file_size" do + @dummy.avatar = @file + @dummy.save + @dummy = Dummy.find(@dummy.id) + assert_equal File.size(@file), @dummy.avatar.size + end + end + + context "and avatar_fingerprint column" do + before do + ActiveRecord::Base.connection.add_column :dummies, :avatar_fingerprint, :string + rebuild_class + @dummy = Dummy.new + end + + it "does not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + context "with explicitly set digest" do + before do + rebuild_class adapter_options: { hash_digest: Digest::SHA256 } + @dummy = Dummy.new + end + + it "returns the right value when sent #avatar_fingerprint" do + @dummy.avatar = @file + assert_equal "734016d801a497f5579cdd4ef2ae1d020088c1db754dc434482d76dd5486520a", + @dummy.avatar_fingerprint + end + + it "returns the right value when saved, reloaded, and sent #avatar_fingerprint" do + @dummy.avatar = @file + @dummy.save + @dummy = Dummy.find(@dummy.id) + assert_equal "734016d801a497f5579cdd4ef2ae1d020088c1db754dc434482d76dd5486520a", + @dummy.avatar_fingerprint + end + end + + context "with the default digest" do + before do + rebuild_class # MD5 is the default + @dummy = Dummy.new + end + + it "returns the right value when sent #avatar_fingerprint" do + @dummy.avatar = @file + assert_equal "aec488126c3b33c08a10c3fa303acf27", + @dummy.avatar_fingerprint + end + + it "returns the right value when saved, reloaded, and sent #avatar_fingerprint" do + @dummy.avatar = @file + @dummy.save + @dummy = Dummy.find(@dummy.id) + assert_equal "aec488126c3b33c08a10c3fa303acf27", + @dummy.avatar_fingerprint + end + end + end + end + + context "an attachment with delete_file option set to false" do + before do + rebuild_model preserve_files: true + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy.avatar = @file + @dummy.save! + @attachment = @dummy.avatar + @path = @attachment.path + end + + after { @file.close } + + it "does not delete the files from storage when attachment is destroyed" do + @attachment.destroy + assert_file_exists(@path) + end + + it "clears out attachment data when attachment is destroyed" do + @attachment.destroy + assert !@attachment.exists? + assert_nil @dummy.avatar_file_name + end + + it "does not delete the file when model is destroyed" do + @dummy.destroy + assert_file_exists(@path) + end + end + + context "An attached file" do + before do + rebuild_model + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy.avatar = @file + @dummy.save! + @attachment = @dummy.avatar + @path = @attachment.path + end + + after { @file.close } + + it "is not deleted when the model fails to destroy" do + @dummy.stubs(:destroy).raises(Exception) + + assert_raises Exception do + @dummy.destroy + end + + assert_file_exists(@path) + end + + it "is deleted when the model is destroyed" do + @dummy.destroy + assert_file_not_exists(@path) + end + + it "is not deleted when transaction rollbacks after model is destroyed" do + ActiveRecord::Base.transaction do + @dummy.destroy + raise ActiveRecord::Rollback + end + + assert_file_exists(@path) + end + end +end diff --git a/spec/paperclip/content_type_detector_spec.rb b/spec/paperclip/content_type_detector_spec.rb new file mode 100644 index 000000000..f5b457518 --- /dev/null +++ b/spec/paperclip/content_type_detector_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Paperclip::ContentTypeDetector do + it 'returns a meaningful content type for open xml spreadsheets' do + file = File.new(fixture_file("empty.xlsx")) + assert_equal "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Paperclip::ContentTypeDetector.new(file.path).detect + end + + it 'gives a sensible default when the name is empty' do + assert_equal "application/octet-stream", Paperclip::ContentTypeDetector.new("").detect + end + + it 'returns the empty content type when the file is empty' do + tempfile = Tempfile.new("empty") + assert_equal "inode/x-empty", Paperclip::ContentTypeDetector.new(tempfile.path).detect + tempfile.close + end + + it 'returns content type of file if it is an acceptable type' do + MIME::Types.stubs(:type_for).returns([MIME::Type.new('application/mp4'), MIME::Type.new('video/mp4'), MIME::Type.new('audio/mp4')]) + Paperclip::ContentTypeDetector.any_instance + .stubs(:type_from_file_contents).returns("video/mp4") + @filename = "my_file.mp4" + assert_equal "video/mp4", Paperclip::ContentTypeDetector.new(@filename).detect + end + + it 'finds the right type in the list via the file command' do + @filename = "#{Dir.tmpdir}/something.hahalolnotreal" + File.open(@filename, "w+") do |file| + file.puts "This is a text file." + file.rewind + assert_equal "text/plain", Paperclip::ContentTypeDetector.new(file.path).detect + end + FileUtils.rm @filename + end + + it 'returns a sensible default if something is wrong, like the file is gone' do + @filename = "/path/to/nothing" + assert_equal "application/octet-stream", Paperclip::ContentTypeDetector.new(@filename).detect + end + + it 'returns a sensible default when the file command is missing' do + Paperclip.stubs(:run).raises(Terrapin::CommandLineError.new) + @filename = "/path/to/something" + assert_equal "application/octet-stream", Paperclip::ContentTypeDetector.new(@filename).detect + end +end diff --git a/spec/paperclip/file_command_content_type_detector_spec.rb b/spec/paperclip/file_command_content_type_detector_spec.rb new file mode 100644 index 000000000..8f8c01704 --- /dev/null +++ b/spec/paperclip/file_command_content_type_detector_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Paperclip::FileCommandContentTypeDetector do + it 'returns a content type based on the content of the file' do + tempfile = Tempfile.new("something") + tempfile.write("This is a file.") + tempfile.rewind + + assert_equal "text/plain", Paperclip::FileCommandContentTypeDetector.new(tempfile.path).detect + + tempfile.close + end + + it 'returns a sensible default when the file command is missing' do + Paperclip.stubs(:run).raises(Terrapin::CommandLineError.new) + @filename = "/path/to/something" + assert_equal "application/octet-stream", + Paperclip::FileCommandContentTypeDetector.new(@filename).detect + end + + it 'returns a sensible default on the odd chance that run returns nil' do + Paperclip.stubs(:run).returns(nil) + assert_equal "application/octet-stream", + Paperclip::FileCommandContentTypeDetector.new("windows").detect + end + + context "#type_from_file_command" do + let(:detector) { Paperclip::FileCommandContentTypeDetector.new("html") } + + it "does work with the output of old versions of file" do + Paperclip.stubs(:run).returns("text/html charset=us-ascii") + expect(detector.detect).to eq("text/html") + end + + it "does work with the output of new versions of file" do + Paperclip.stubs(:run).returns("text/html; charset=us-ascii") + expect(detector.detect).to eq("text/html") + end + end +end diff --git a/spec/paperclip/filename_cleaner_spec.rb b/spec/paperclip/filename_cleaner_spec.rb new file mode 100644 index 000000000..a9a575496 --- /dev/null +++ b/spec/paperclip/filename_cleaner_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Paperclip::FilenameCleaner do + it 'converts invalid characters to underscores' do + cleaner = Paperclip::FilenameCleaner.new(/[aeiou]/) + expect(cleaner.call("baseball")).to eq "b_s_b_ll" + end + + it 'does not convert anything if the character regex is nil' do + cleaner = Paperclip::FilenameCleaner.new(nil) + expect(cleaner.call("baseball")).to eq "baseball" + end +end diff --git a/spec/paperclip/geometry_detector_spec.rb b/spec/paperclip/geometry_detector_spec.rb new file mode 100644 index 000000000..7b8a8dadc --- /dev/null +++ b/spec/paperclip/geometry_detector_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe Paperclip::GeometryDetector do + it 'identifies an image and extract its dimensions' do + Paperclip::GeometryParser.stubs(:new).with("434x66,").returns(stub(make: :correct)) + file = fixture_file("5k.png") + factory = Paperclip::GeometryDetector.new(file) + + output = factory.make + + expect(output).to eq :correct + end + + it 'identifies an image and extract its dimensions and orientation' do + Paperclip::GeometryParser.stubs(:new).with("300x200,6").returns(stub(make: :correct)) + file = fixture_file("rotated.jpg") + factory = Paperclip::GeometryDetector.new(file) + + output = factory.make + + expect(output).to eq :correct + end + + it 'avoids reading EXIF orientation if so configured' do + begin + Paperclip.options[:use_exif_orientation] = false + Paperclip::GeometryParser.stubs(:new).with("300x200,1").returns(stub(make: :correct)) + file = fixture_file("rotated.jpg") + factory = Paperclip::GeometryDetector.new(file) + + output = factory.make + + expect(output).to eq :correct + ensure + Paperclip.options[:use_exif_orientation] = true + end + end +end + diff --git a/spec/paperclip/geometry_parser_spec.rb b/spec/paperclip/geometry_parser_spec.rb new file mode 100644 index 000000000..82ee4a09d --- /dev/null +++ b/spec/paperclip/geometry_parser_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +describe Paperclip::GeometryParser do + it 'identifies an image and extract its dimensions with no orientation' do + Paperclip::Geometry.stubs(:new).with( + height: '73', + width: '434', + modifier: nil, + orientation: nil + ).returns(:correct) + factory = Paperclip::GeometryParser.new("434x73") + + output = factory.make + + assert_equal :correct, output + end + + it 'identifies an image and extract its dimensions with an empty orientation' do + Paperclip::Geometry.stubs(:new).with( + height: '73', + width: '434', + modifier: nil, + orientation: '' + ).returns(:correct) + factory = Paperclip::GeometryParser.new("434x73,") + + output = factory.make + + assert_equal :correct, output + end + + it 'identifies an image and extract its dimensions and orientation' do + Paperclip::Geometry.stubs(:new).with( + height: '200', + width: '300', + modifier: nil, + orientation: '6' + ).returns(:correct) + factory = Paperclip::GeometryParser.new("300x200,6") + + output = factory.make + + assert_equal :correct, output + end + + it 'identifies an image and extract its dimensions and modifier' do + Paperclip::Geometry.stubs(:new).with( + height: '64', + width: '64', + modifier: '#', + orientation: nil + ).returns(:correct) + factory = Paperclip::GeometryParser.new("64x64#") + + output = factory.make + + assert_equal :correct, output + end + + it 'identifies an image and extract its dimensions, orientation, and modifier' do + Paperclip::Geometry.stubs(:new).with( + height: '50', + width: '100', + modifier: '>', + orientation: '7' + ).returns(:correct) + factory = Paperclip::GeometryParser.new("100x50,7>") + + output = factory.make + + assert_equal :correct, output + end +end diff --git a/spec/paperclip/geometry_spec.rb b/spec/paperclip/geometry_spec.rb new file mode 100644 index 000000000..6dbffe884 --- /dev/null +++ b/spec/paperclip/geometry_spec.rb @@ -0,0 +1,255 @@ +require 'spec_helper' + +describe Paperclip::Geometry do + context "Paperclip::Geometry" do + it "correctly reports its given dimensions" do + assert @geo = Paperclip::Geometry.new(1024, 768) + assert_equal 1024, @geo.width + assert_equal 768, @geo.height + end + + it "sets height to 0 if height dimension is missing" do + assert @geo = Paperclip::Geometry.new(1024) + assert_equal 1024, @geo.width + assert_equal 0, @geo.height + end + + it "sets width to 0 if width dimension is missing" do + assert @geo = Paperclip::Geometry.new(nil, 768) + assert_equal 0, @geo.width + assert_equal 768, @geo.height + end + + it "is generated from a WxH-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x600") + assert_equal 800, @geo.width + assert_equal 600, @geo.height + end + + it "is generated from a xH-formatted string" do + assert @geo = Paperclip::Geometry.parse("x600") + assert_equal 0, @geo.width + assert_equal 600, @geo.height + end + + it "is generated from a Wx-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x") + assert_equal 800, @geo.width + assert_equal 0, @geo.height + end + + it "is generated from a W-formatted string" do + assert @geo = Paperclip::Geometry.parse("800") + assert_equal 800, @geo.width + assert_equal 0, @geo.height + end + + it "ensures the modifier is nil if not present" do + assert @geo = Paperclip::Geometry.parse("123x456") + assert_nil @geo.modifier + end + + it "recognizes an EXIF orientation and not rotate with auto_orient if not necessary" do + geo = Paperclip::Geometry.new(width: 1024, height: 768, orientation: 1) + assert geo + assert_equal 1024, geo.width + assert_equal 768, geo.height + + geo.auto_orient + + assert_equal 1024, geo.width + assert_equal 768, geo.height + end + + it "recognizes an EXIF orientation and rotate with auto_orient if necessary" do + geo = Paperclip::Geometry.new(width: 1024, height: 768, orientation: 6) + assert geo + assert_equal 1024, geo.width + assert_equal 768, geo.height + + geo.auto_orient + + assert_equal 768, geo.width + assert_equal 1024, geo.height + end + + it "treats x and X the same in geometries" do + @lower = Paperclip::Geometry.parse("123x456") + @upper = Paperclip::Geometry.parse("123X456") + assert_equal 123, @lower.width + assert_equal 123, @upper.width + assert_equal 456, @lower.height + assert_equal 456, @upper.height + end + + ['>', '<', '#', '@', '@>', '>@', '%', '^', '!', nil].each do |mod| + it "ensures the modifier #{description} is preserved" do + assert @geo = Paperclip::Geometry.parse("123x456#{mod}") + assert_equal mod, @geo.modifier + assert_equal "123x456#{mod}", @geo.to_s + end + + it "ensures the modifier #{description} is preserved with no height" do + assert @geo = Paperclip::Geometry.parse("123x#{mod}") + assert_equal mod, @geo.modifier + assert_equal "123#{mod}", @geo.to_s + end + end + + it "makes sure the modifier gets passed during transformation_to" do + assert @src = Paperclip::Geometry.parse("123x456") + assert @dst = Paperclip::Geometry.parse("123x456>") + assert_equal ["123x456>", nil], @src.transformation_to(@dst) + end + + it "generates correct ImageMagick formatting string for W-formatted string" do + assert @geo = Paperclip::Geometry.parse("800") + assert_equal "800", @geo.to_s + end + + it "generates correct ImageMagick formatting string for Wx-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x") + assert_equal "800", @geo.to_s + end + + it "generates correct ImageMagick formatting string for xH-formatted string" do + assert @geo = Paperclip::Geometry.parse("x600") + assert_equal "x600", @geo.to_s + end + + it "generates correct ImageMagick formatting string for WxH-formatted string" do + assert @geo = Paperclip::Geometry.parse("800x600") + assert_equal "800x600", @geo.to_s + end + + it "is generated from a file" do + file = fixture_file("5k.png") + file = File.new(file, 'rb') + assert_nothing_raised{ @geo = Paperclip::Geometry.from_file(file) } + assert_equal 66, @geo.height + assert_equal 434, @geo.width + end + + it "is generated from a file path" do + file = fixture_file("5k.png") + assert_nothing_raised{ @geo = Paperclip::Geometry.from_file(file) } + assert_equal 66, @geo.height + assert_equal 434, @geo.width + end + + it 'calculates an EXIF-rotated image dimensions from a path' do + file = fixture_file("rotated.jpg") + assert_nothing_raised{ @geo = Paperclip::Geometry.from_file(file) } + @geo.auto_orient + assert_equal 300, @geo.height + assert_equal 200, @geo.width + end + + it "does not generate from a bad file" do + file = "/home/This File Does Not Exist.omg" + expect { @geo = Paperclip::Geometry.from_file(file) }.to raise_error(Paperclip::Errors::NotIdentifiedByImageMagickError) + end + + it "does not generate from a blank filename" do + file = "" + expect { @geo = Paperclip::Geometry.from_file(file) }.to raise_error(Paperclip::Errors::NotIdentifiedByImageMagickError) + end + + it "does not generate from a nil file" do + file = nil + expect { @geo = Paperclip::Geometry.from_file(file) }.to raise_error(Paperclip::Errors::NotIdentifiedByImageMagickError) + end + + it "does not generate from a file with no path" do + file = mock("file", path: "") + file.stubs(:respond_to?).with(:path).returns(true) + expect { @geo = Paperclip::Geometry.from_file(file) }.to raise_error(Paperclip::Errors::NotIdentifiedByImageMagickError) + end + + it "lets us know when a command isn't found versus a processing error" do + old_path = ENV['PATH'] + begin + ENV['PATH'] = '' + assert_raises(Paperclip::Errors::CommandNotFoundError) do + file = fixture_file("5k.png") + @geo = Paperclip::Geometry.from_file(file) + end + ensure + ENV['PATH'] = old_path + end + end + + [['vertical', 900, 1440, true, false, false, 1440, 900, 0.625], + ['horizontal', 1024, 768, false, true, false, 1024, 768, 1.3333], + ['square', 100, 100, false, false, true, 100, 100, 1]].each do |args| + context "performing calculations on a #{args[0]} viewport" do + before do + @geo = Paperclip::Geometry.new(args[1], args[2]) + end + + it "is #{args[3] ? "" : "not"} vertical" do + assert_equal args[3], @geo.vertical? + end + + it "is #{args[4] ? "" : "not"} horizontal" do + assert_equal args[4], @geo.horizontal? + end + + it "is #{args[5] ? "" : "not"} square" do + assert_equal args[5], @geo.square? + end + + it "reports that #{args[6]} is the larger dimension" do + assert_equal args[6], @geo.larger + end + + it "reports that #{args[7]} is the smaller dimension" do + assert_equal args[7], @geo.smaller + end + + it "has an aspect ratio of #{args[8]}" do + expect(@geo.aspect).to be_within(0.0001).of(args[8]) + end + end + end + + [[ [1000, 100], [64, 64], "x64", "64x64+288+0" ], + [ [100, 1000], [50, 950], "x950", "50x950+22+0" ], + [ [100, 1000], [50, 25], "50x", "50x25+0+237" ]]. each do |args| + context "of #{args[0].inspect} and given a Geometry #{args[1].inspect} and sent transform_to" do + before do + @geo = Paperclip::Geometry.new(*args[0]) + @dst = Paperclip::Geometry.new(*args[1]) + @scale, @crop = @geo.transformation_to @dst, true + end + + it "is able to return the correct scaling transformation geometry #{args[2]}" do + assert_equal args[2], @scale + end + + it "is able to return the correct crop transformation geometry #{args[3]}" do + assert_equal args[3], @crop + end + end + end + + [['256x256', {'150x150!' => [150, 150], '150x150#' => [150, 150], '150x150>' => [150, 150], '150x150<' => [256, 256], '150x150' => [150, 150]}], + ['256x256', {'512x512!' => [512, 512], '512x512#' => [512, 512], '512x512>' => [256, 256], '512x512<' => [512, 512], '512x512' => [512, 512]}], + ['600x400', {'512x512!' => [512, 512], '512x512#' => [512, 512], '512x512>' => [512, 341], '512x512<' => [600, 400], '512x512' => [512, 341]}]].each do |original_size, options| + options.each_pair do |size, dimensions| + context "#{original_size} resize_to #{size}" do + before do + @source = Paperclip::Geometry.parse original_size + @new_geometry = @source.resize_to size + end + it "has #{dimensions.first} width" do + assert_equal dimensions.first, @new_geometry.width + end + it "has #{dimensions.last} height" do + assert_equal dimensions.last, @new_geometry.height + end + end + end + end + end +end diff --git a/spec/paperclip/glue_spec.rb b/spec/paperclip/glue_spec.rb new file mode 100644 index 000000000..e03df26bf --- /dev/null +++ b/spec/paperclip/glue_spec.rb @@ -0,0 +1,44 @@ +# require "spec_helper" + +describe Paperclip::Glue do + describe "when ActiveRecord does not exist" do + before do + ActiveRecordSaved = ActiveRecord + Object.send :remove_const, "ActiveRecord" + end + + after do + ActiveRecord = ActiveRecordSaved + Object.send :remove_const, "ActiveRecordSaved" + end + + it "does not fail" do + NonActiveRecordModel = Class.new + NonActiveRecordModel.send :include, Paperclip::Glue + Object.send :remove_const, "NonActiveRecordModel" + end + end + + describe "when ActiveRecord does exist" do + before do + if Object.const_defined?("ActiveRecord") + @defined_active_record = false + else + ActiveRecord = :defined + @defined_active_record = true + end + end + + after do + if @defined_active_record + Object.send :remove_const, "ActiveRecord" + end + end + + it "does not fail" do + NonActiveRecordModel = Class.new + NonActiveRecordModel.send :include, Paperclip::Glue + Object.send :remove_const, "NonActiveRecordModel" + end + end +end diff --git a/spec/paperclip/has_attached_file_spec.rb b/spec/paperclip/has_attached_file_spec.rb new file mode 100644 index 000000000..b05bd2050 --- /dev/null +++ b/spec/paperclip/has_attached_file_spec.rb @@ -0,0 +1,158 @@ +require 'spec_helper' + +describe Paperclip::HasAttachedFile do + context '#define_on' do + it 'defines a setter on the class object' do + assert_adding_attachment('avatar').defines_method('avatar=') + end + + it 'defines a getter on the class object' do + assert_adding_attachment('avatar').defines_method('avatar') + end + + it 'defines a query on the class object' do + assert_adding_attachment('avatar').defines_method('avatar?') + end + + it 'defines a method on the class to get all of its attachments' do + assert_adding_attachment('avatar').defines_class_method('attachment_definitions') + end + + it 'flushes errors as part of validations' do + assert_adding_attachment('avatar').defines_validation + end + + it 'registers the attachment with Paperclip::AttachmentRegistry' do + assert_adding_attachment('avatar').registers_attachment + end + + it 'defines an after_save callback' do + assert_adding_attachment('avatar').defines_callback('after_save') + end + + it 'defines a before_destroy callback' do + assert_adding_attachment('avatar').defines_callback('before_destroy') + end + + it 'defines an after_commit callback' do + assert_adding_attachment('avatar').defines_callback('after_commit') + end + + context 'when the class does not allow after_commit callbacks' do + it 'defines an after_destroy callback' do + assert_adding_attachment( + 'avatar', + unstub_methods: [:after_commit] + ).defines_callback('after_destroy') + end + end + + it 'defines the Paperclip-specific callbacks' do + assert_adding_attachment('avatar').defines_callback('define_paperclip_callbacks') + end + + it 'does not define a media_type check if told not to' do + assert_adding_attachment('avatar').does_not_set_up_media_type_check_validation + end + + it 'does define a media_type check if told to' do + assert_adding_attachment('avatar').sets_up_media_type_check_validation + end + end + + private + + def assert_adding_attachment(attachment_name, options={}) + AttachmentAdder.new(attachment_name, options) + end + + class AttachmentAdder + include Mocha::API + include RSpec::Matchers + + def initialize(attachment_name, options = {}) + @attachment_name = attachment_name + @stubbed_class = stub_class + if options.present? + options[:unstub_methods].each do |method| + @stubbed_class.unstub(method) + end + end + end + + def defines_method(method_name) + a_class = @stubbed_class + + Paperclip::HasAttachedFile.define_on(a_class, @attachment_name, {}) + + expect(a_class).to have_received(:define_method).with(method_name) + end + + def defines_class_method(method_name) + a_class = @stubbed_class + a_class.class.stubs(:define_method) + + Paperclip::HasAttachedFile.define_on(a_class, @attachment_name, {}) + + expect(a_class).to have_received(:extend).with(Paperclip::HasAttachedFile::ClassMethods) + end + + def defines_validation + a_class = @stubbed_class + + Paperclip::HasAttachedFile.define_on(a_class, @attachment_name, {}) + + expect(a_class).to have_received(:validates_each).with(@attachment_name) + end + + def registers_attachment + a_class = @stubbed_class + Paperclip::AttachmentRegistry.stubs(:register) + + Paperclip::HasAttachedFile.define_on(a_class, @attachment_name, {size: 1}) + + expect(Paperclip::AttachmentRegistry).to have_received(:register).with(a_class, @attachment_name, {size: 1}) + end + + def defines_callback(callback_name) + a_class = @stubbed_class + + Paperclip::HasAttachedFile.define_on(a_class, @attachment_name, {}) + + expect(a_class).to have_received(callback_name.to_sym) + end + + def does_not_set_up_media_type_check_validation + a_class = stub_class + + Paperclip::HasAttachedFile.define_on(a_class, @attachment_name, { validate_media_type: false }) + + expect(a_class).to have_received(:validates_media_type_spoof_detection).never + end + + def sets_up_media_type_check_validation + a_class = stub_class + + Paperclip::HasAttachedFile.define_on(a_class, @attachment_name, { validate_media_type: true }) + + expect(a_class).to have_received(:validates_media_type_spoof_detection) + end + + private + + def stub_class + stub('class', + validates_each: nil, + define_method: nil, + after_save: nil, + before_destroy: nil, + after_commit: nil, + after_destroy: nil, + define_paperclip_callbacks: nil, + extend: nil, + name: 'Billy', + validates_media_type_spoof_detection: nil + ) + end + end +end diff --git a/spec/paperclip/integration_spec.rb b/spec/paperclip/integration_spec.rb new file mode 100644 index 000000000..35c2a4097 --- /dev/null +++ b/spec/paperclip/integration_spec.rb @@ -0,0 +1,667 @@ +require 'spec_helper' +require 'open-uri' + +describe 'Paperclip' do + context "Many models at once" do + before do + rebuild_model + @file = File.new(fixture_file("5k.png"), 'rb') + # Deals with `Too many open files` error + Dummy.import 100.times.map { Dummy.new avatar: @file } + Dummy.import 100.times.map { Dummy.new avatar: @file } + Dummy.import 100.times.map { Dummy.new avatar: @file } + end + + after { @file.close } + + it "does not exceed the open file limit" do + assert_nothing_raised do + Dummy.all.each { |dummy| dummy.avatar } + end + end + end + + context "An attachment" do + before do + rebuild_model styles: { thumb: "50x50#" } + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy.avatar = @file + assert @dummy.save + end + + after { @file.close } + + it "creates its thumbnails properly" do + assert_match(/\b50x50\b/, `identify "#{@dummy.avatar.path(:thumb)}"`) + end + + context 'reprocessing with unreadable original' do + before { File.chmod(0000, @dummy.avatar.path) } + + it "does not raise an error" do + assert_nothing_raised do + silence_stream(STDERR) do + @dummy.avatar.reprocess! + end + end + end + + it "returns false" do + silence_stream(STDERR) do + assert !@dummy.avatar.reprocess! + end + end + + after { File.chmod(0644, @dummy.avatar.path) } + end + + context "redefining its attachment styles" do + before do + Dummy.class_eval do + has_attached_file :avatar, styles: { thumb: "150x25#", dynamic: lambda { |a| '50x50#' } } + end + @d2 = Dummy.find(@dummy.id) + @original_timestamp = @d2.avatar_updated_at + @d2.avatar.reprocess! + @d2.save + end + + it "creates its thumbnails properly" do + assert_match(/\b150x25\b/, `identify "#{@dummy.avatar.path(:thumb)}"`) + assert_match(/\b50x50\b/, `identify "#{@dummy.avatar.path(:dynamic)}"`) + end + + it "changes the timestamp" do + assert_not_equal @original_timestamp, @d2.avatar_updated_at + end + end + end + + context "Attachment" do + before do + @thumb_path = "tmp/public/system/dummies/avatars/000/000/001/thumb/5k.png" + File.delete(@thumb_path) if File.exist?(@thumb_path) + rebuild_model styles: { thumb: "50x50#" } + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + + end + + after { @file.close } + + it "does not create the thumbnails upon saving when post-processing is disabled" do + @dummy.avatar.post_processing = false + @dummy.avatar = @file + assert @dummy.save + assert_file_not_exists @thumb_path + end + + it "creates the thumbnails upon saving when post_processing is enabled" do + @dummy.avatar.post_processing = true + @dummy.avatar = @file + assert @dummy.save + assert_file_exists @thumb_path + end + end + + context "Attachment with no generated thumbnails" do + before do + @thumb_small_path = "tmp/public/system/dummies/avatars/000/000/001/thumb_small/5k.png" + @thumb_large_path = "tmp/public/system/dummies/avatars/000/000/001/thumb_large/5k.png" + File.delete(@thumb_small_path) if File.exist?(@thumb_small_path) + File.delete(@thumb_large_path) if File.exist?(@thumb_large_path) + rebuild_model styles: { thumb_small: "50x50#", thumb_large: "60x60#" } + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + + @dummy.avatar.post_processing = false + @dummy.avatar = @file + assert @dummy.save + @dummy.avatar.post_processing = true + end + + after { @file.close } + + it "allows us to create all thumbnails in one go" do + assert_file_not_exists(@thumb_small_path) + assert_file_not_exists(@thumb_large_path) + + @dummy.avatar.reprocess! + + assert_file_exists(@thumb_small_path) + assert_file_exists(@thumb_large_path) + end + + it "allows us to selectively create each thumbnail" do + assert_file_not_exists(@thumb_small_path) + assert_file_not_exists(@thumb_large_path) + + @dummy.avatar.reprocess! :thumb_small + assert_file_exists(@thumb_small_path) + assert_file_not_exists(@thumb_large_path) + + @dummy.avatar.reprocess! :thumb_large + assert_file_exists(@thumb_large_path) + end + end + + context "A model that modifies its original" do + before do + rebuild_model styles: { original: "2x2#" } + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy.avatar = @file + end + + it "reports the file size of the processed file and not the original" do + assert_not_equal File.size(@file.path), @dummy.avatar.size + end + + after { @file.close } + end + + context "A model with attachments scoped under an id" do + before do + rebuild_model styles: { large: "100x100", + medium: "50x50" }, + path: ":rails_root/tmp/:id/:attachments/:style.:extension" + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy.avatar = @file + end + + after { @file.close } + + context "when saved" do + before do + @dummy.save + @saved_path = @dummy.avatar.path(:large) + end + + it "has a large file in the right place" do + assert_file_exists(@dummy.avatar.path(:large)) + end + + context "and deleted" do + before do + @dummy.avatar.clear + @dummy.save + end + + it "does not have a large file in the right place anymore" do + assert_file_not_exists(@saved_path) + end + + it "does not have its next two parent directories" do + assert_file_not_exists(File.dirname(@saved_path)) + assert_file_not_exists(File.dirname(File.dirname(@saved_path))) + end + end + + context 'and deleted where the delete fails' do + it "does not die if an unexpected SystemCallError happens" do + FileUtils.stubs(:rmdir).raises(Errno::EPIPE) + assert_nothing_raised do + @dummy.avatar.clear + @dummy.save + end + end + end + end + end + + [000,002,022].each do |umask| + context "when the umask is #{umask}" do + before do + rebuild_model + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + @umask = File.umask(umask) + end + + after do + File.umask @umask + @file.close + end + + it "respects the current umask" do + @dummy.avatar = @file + @dummy.save + assert_equal 0666&~umask, 0666&File.stat(@dummy.avatar.path).mode + end + end + end + + [0666,0664,0640].each do |perms| + context "when the perms are #{perms}" do + before do + rebuild_model override_file_permissions: perms + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + end + + after do + @file.close + end + + it "respects the current perms" do + @dummy.avatar = @file + @dummy.save + assert_equal perms, File.stat(@dummy.avatar.path).mode & 0777 + end + end + end + + it "skips chmod operation, when override_file_permissions is set to false (e.g. useful when using CIFS mounts)" do + FileUtils.expects(:chmod).never + + rebuild_model override_file_permissions: false + dummy = Dummy.create! + dummy.avatar = @file + dummy.save + end + + context "A model with a filesystem attachment" do + before do + rebuild_model styles: { large: "300x300>", + medium: "100x100", + thumb: ["32x32#", :gif] }, + default_style: :medium, + url: "/:attachment/:class/:style/:id/:basename.:extension", + path: ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" + @dummy = Dummy.new + @file = File.new(fixture_file("5k.png"), 'rb') + @bad_file = File.new(fixture_file("bad.png"), 'rb') + + assert @dummy.avatar = @file + assert @dummy.valid?, @dummy.errors.full_messages.join(", ") + assert @dummy.save + end + + after { [@file, @bad_file].each(&:close) } + + it "writes and delete its files" do + [["434x66", :original], + ["300x46", :large], + ["100x15", :medium], + ["32x32", :thumb]].each do |geo, style| + cmd = %Q[identify -format "%wx%h" "#{@dummy.avatar.path(style)}"] + assert_equal geo, `#{cmd}`.chomp, cmd + end + + saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) } + + @d2 = Dummy.find(@dummy.id) + assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path}"`.chomp + assert_equal "434x66", `identify -format "%wx%h" "#{@d2.avatar.path(:original)}"`.chomp + assert_equal "300x46", `identify -format "%wx%h" "#{@d2.avatar.path(:large)}"`.chomp + assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path(:medium)}"`.chomp + assert_equal "32x32", `identify -format "%wx%h" "#{@d2.avatar.path(:thumb)}"`.chomp + + assert @dummy.valid? + assert @dummy.save + + saved_paths.each do |p| + assert_file_exists(p) + end + + @dummy.avatar.clear + assert_nil @dummy.avatar_file_name + assert @dummy.valid? + assert @dummy.save + + saved_paths.each do |p| + assert_file_not_exists(p) + end + + @d2 = Dummy.find(@dummy.id) + assert_nil @d2.avatar_file_name + end + + it "works exactly the same when new as when reloaded" do + @d2 = Dummy.find(@dummy.id) + + assert_equal @dummy.avatar_file_name, @d2.avatar_file_name + [:thumb, :medium, :large, :original].each do |style| + assert_equal @dummy.avatar.path(style), @d2.avatar.path(style) + end + + saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) } + + @d2.avatar.clear + assert @d2.save + + saved_paths.each do |p| + assert_file_not_exists(p) + end + end + + it "does not abide things that don't have adapters" do + assert_raises(Paperclip::AdapterRegistry::NoHandlerError) do + @dummy.avatar = "not a file" + end + end + + it "is not ok with bad files" do + @dummy.avatar = @bad_file + assert ! @dummy.valid? + end + + it "knows the difference between good files, bad files, and not files when validating" do + Dummy.validates_attachment_presence :avatar + @d2 = Dummy.find(@dummy.id) + @d2.avatar = @file + assert @d2.valid?, @d2.errors.full_messages.inspect + @d2.avatar = @bad_file + assert ! @d2.valid? + end + + it "is able to reload without saving and not have the file disappear" do + @dummy.avatar = @file + assert @dummy.save, @dummy.errors.full_messages.inspect + @dummy.avatar.clear + assert_nil @dummy.avatar_file_name + @dummy.reload + assert_equal "5k.png", @dummy.avatar_file_name + end + + context "that is assigned its file from another Paperclip attachment" do + before do + @dummy2 = Dummy.new + @file2 = File.new(fixture_file("12k.png"), 'rb') + assert @dummy2.avatar = @file2 + @dummy2.save + end + + after { @file2.close } + + it "works when assigned a file" do + assert_not_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`, + `identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"` + + assert @dummy.avatar = @dummy2.avatar + @dummy.save + assert_equal @dummy.avatar_file_name, @dummy2.avatar_file_name + assert_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`, + `identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"` + end + end + + end + + context "A model with an attachments association and a Paperclip attachment" do + before do + Dummy.class_eval do + has_many :attachments, class_name: 'Dummy' + end + + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + it "does not error when saving" do + @dummy.save! + end + end + + context "A model with an attachment with hash in file name" do + before do + @settings = { styles: { thumb: "50x50#" }, + path: ":rails_root/public/system/:attachment/:id_partition/:style/:hash.:extension", + url: "/system/:attachment/:id_partition/:style/:hash.:extension", + hash_secret: "somesecret" } + + rebuild_model @settings + + @file = File.new(fixture_file("5k.png"), 'rb') + @dummy = Dummy.create! avatar: @file + end + + after do + @file.close + end + + it "is accessible" do + assert_file_exists(@dummy.avatar.path(:original)) + assert_file_exists(@dummy.avatar.path(:thumb)) + end + + context "when new style is added" do + before do + @dummy.avatar.options[:styles][:mini] = "25x25#" + @dummy.avatar.instance_variable_set :@normalized_styles, nil + Time.stubs(now: Time.now + 10) + @dummy.avatar.reprocess! + @dummy.reload + end + + it "makes all the styles accessible" do + assert_file_exists(@dummy.avatar.path(:original)) + assert_file_exists(@dummy.avatar.path(:thumb)) + assert_file_exists(@dummy.avatar.path(:mini)) + end + end + end + + if ENV['S3_BUCKET'] + def s3_files_for attachment + [:thumb, :medium, :large, :original].inject({}) do |files, style| + data = `curl "#{attachment.url(style)}" 2>/dev/null`.chomp + t = Tempfile.new("paperclip-test") + t.binmode + t.write(data) + t.rewind + files[style] = t + files + end + end + + def s3_headers_for attachment, style + `curl --head "#{attachment.url(style)}" 2>/dev/null`.split("\n").inject({}) do |h,head| + split_head = head.chomp.split(/\s*:\s*/, 2) + h[split_head.first.downcase] = split_head.last unless split_head.empty? + h + end + end + + context "A model with an S3 attachment" do + before do + rebuild_model( + styles: { + large: "300x300>", + medium: "100x100", + thumb: ["32x32#", :gif], + custom: { + geometry: "32x32#", + s3_headers: { 'Cache-Control' => 'max-age=31557600' }, + s3_metadata: { 'foo' => 'bar'} + } + }, + storage: :s3, + s3_credentials: File.new(fixture_file('s3.yml')), + s3_options: { logger: Paperclip.logger }, + default_style: :medium, + bucket: ENV['S3_BUCKET'], + path: ":class/:attachment/:id/:style/:basename.:extension" + ) + + @dummy = Dummy.new + @file = File.new(fixture_file('5k.png'), 'rb') + @bad_file = File.new(fixture_file('bad.png'), 'rb') + + @dummy.avatar = @file + @dummy.valid? + @dummy.save! + + @files_on_s3 = s3_files_for(@dummy.avatar) + end + + after do + @file.close + @bad_file.close + @files_on_s3.values.each(&:close) if @files_on_s3 + end + + context 'assigning itself to a new model' do + before do + @d2 = Dummy.new + @d2.avatar = @dummy.avatar + @d2.save + end + + it "has the same name as the old file" do + assert_equal @d2.avatar.original_filename, @dummy.avatar.original_filename + end + end + + it "has the same contents as the original" do + assert_equal @file.read, @files_on_s3[:original].read + end + + it "writes and delete its files" do + [["434x66", :original], + ["300x46", :large], + ["100x15", :medium], + ["32x32", :thumb]].each do |geo, style| + cmd = %Q[identify -format "%wx%h" "#{@files_on_s3[style].path}"] + assert_equal geo, `#{cmd}`.chomp, cmd + end + + @d2 = Dummy.find(@dummy.id) + @d2_files = s3_files_for @d2.avatar + [["434x66", :original], + ["300x46", :large], + ["100x15", :medium], + ["32x32", :thumb]].each do |geo, style| + cmd = %Q[identify -format "%wx%h" "#{@d2_files[style].path}"] + assert_equal geo, `#{cmd}`.chomp, cmd + end + + @dummy.avatar.clear + assert_nil @dummy.avatar_file_name + assert @dummy.valid? + assert @dummy.save + + [:thumb, :medium, :large, :original].each do |style| + assert ! @dummy.avatar.exists?(style) + end + + @d2 = Dummy.find(@dummy.id) + assert_nil @d2.avatar_file_name + end + + it "works exactly the same when new as when reloaded" do + @d2 = Dummy.find(@dummy.id) + + assert_equal @dummy.avatar_file_name, @d2.avatar_file_name + + [:thumb, :medium, :large, :original].each do |style| + begin + first_file = open(@dummy.avatar.url(style)) + second_file = open(@dummy.avatar.url(style)) + assert_equal first_file.read, second_file.read + ensure + first_file.close if first_file + second_file.close if second_file + end + end + + @d2.avatar.clear + assert @d2.save + + [:thumb, :medium, :large, :original].each do |style| + assert ! @dummy.avatar.exists?(style) + end + end + + it "knows the difference between good files, bad files, and nil" do + @dummy.avatar = @bad_file + assert ! @dummy.valid? + @dummy.avatar = nil + assert @dummy.valid? + + Dummy.validates_attachment_presence :avatar + @d2 = Dummy.find(@dummy.id) + @d2.avatar = @file + assert @d2.valid? + @d2.avatar = @bad_file + assert ! @d2.valid? + @d2.avatar = nil + assert ! @d2.valid? + end + + it "is able to reload without saving and not have the file disappear" do + @dummy.avatar = @file + assert @dummy.save + @dummy.avatar = nil + assert_nil @dummy.avatar_file_name + @dummy.reload + assert_equal "5k.png", @dummy.avatar_file_name + end + + it "has the right content type" do + headers = s3_headers_for(@dummy.avatar, :original) + assert_equal 'image/png', headers['content-type'] + end + + it "has the right style-specific headers" do + headers = s3_headers_for(@dummy.avatar, :custom) + assert_equal 'max-age=31557600', headers['cache-control'] + end + + it "has the right style-specific metadata" do + headers = s3_headers_for(@dummy.avatar, :custom) + assert_equal 'bar', headers['x-amz-meta-foo'] + end + + context "with non-english character in the file name" do + before do + @file.stubs(:original_filename).returns("クリップ.png") + @dummy.avatar = @file + end + + it "does not raise any error" do + @dummy.save! + end + end + end + end + + context "Copying attachments between models" do + before do + rebuild_model + @file = File.new(fixture_file("5k.png"), 'rb') + end + + after { @file.close } + + it "succeeds when original attachment is a file" do + original = Dummy.new + original.avatar = @file + assert original.save + + copy = Dummy.new + copy.avatar = original.avatar + assert copy.save + + assert copy.avatar.present? + end + + it "succeeds when original attachment is empty" do + original = Dummy.create! + + copy = Dummy.new + copy.avatar = @file + assert copy.save + assert copy.avatar.present? + + copy.avatar = original.avatar + assert copy.save + assert !copy.avatar.present? + end + end +end diff --git a/test/interpolations_test.rb b/spec/paperclip/interpolations_spec.rb similarity index 58% rename from test/interpolations_test.rb rename to spec/paperclip/interpolations_spec.rb index e2ccc42da..c8fdd607b 100644 --- a/test/interpolations_test.rb +++ b/spec/paperclip/interpolations_spec.rb @@ -1,7 +1,7 @@ -require './test/helper' +require 'spec_helper' -class InterpolationsTest < Test::Unit::TestCase - should "return all methods but the infrastructure when sent #all" do +describe Paperclip::Interpolations do + it "returns all methods but the infrastructure when sent #all" do methods = Paperclip::Interpolations.all assert ! methods.include?(:[]) assert ! methods.include?(:[]=) @@ -11,175 +11,218 @@ class InterpolationsTest < Test::Unit::TestCase end end - should "return the Rails.root" do + it "returns the Rails.root" do assert_equal Rails.root, Paperclip::Interpolations.rails_root(:attachment, :style) end - should "return the Rails.env" do + it "returns the Rails.env" do assert_equal Rails.env, Paperclip::Interpolations.rails_env(:attachment, :style) end - should "return the class of the Interpolations module when called with no params" do + it "returns the class of the Interpolations module when called with no params" do assert_equal Module, Paperclip::Interpolations.class end - should "return the class of the instance" do + it "returns the class of the instance" do + class Thing ; end attachment = mock attachment.expects(:instance).returns(attachment) - attachment.expects(:class).returns("Thing") + attachment.expects(:class).returns(Thing) assert_equal "things", Paperclip::Interpolations.class(attachment, :style) end - should "return the basename of the file" do + it "returns the basename of the file" do attachment = mock - attachment.expects(:original_filename).returns("one.jpg").times(2) + attachment.expects(:original_filename).returns("one.jpg").times(1) assert_equal "one", Paperclip::Interpolations.basename(attachment, :style) end - should "return the extension of the file" do + it "returns the extension of the file" do attachment = mock attachment.expects(:original_filename).returns("one.jpg") attachment.expects(:styles).returns({}) assert_equal "jpg", Paperclip::Interpolations.extension(attachment, :style) end - should "return the extension of the file as the format if defined in the style" do + it "returns the extension of the file as the format if defined in the style" do attachment = mock attachment.expects(:original_filename).never - attachment.expects(:styles).twice.returns({:style => {:format => "png"}}) + attachment.expects(:styles).twice.returns({style: {format: "png"}}) [:style, 'style'].each do |style| assert_equal "png", Paperclip::Interpolations.extension(attachment, style) end end - should "return the extension of the file based on the content type" do + it "returns the extension of the file based on the content type" do attachment = mock - attachment.expects(:content_type).returns('image/jpeg') + attachment.expects(:content_type).returns('image/png') + attachment.expects(:styles).returns({}) interpolations = Paperclip::Interpolations interpolations.expects(:extension).returns('random') - assert_equal "jpeg", interpolations.content_type_extension(attachment, :style) + assert_equal "png", interpolations.content_type_extension(attachment, :style) end - should "return the original extension of the file if it matches a content type extension" do + it "returns the original extension of the file if it matches a content type extension" do attachment = mock attachment.expects(:content_type).returns('image/jpeg') + attachment.expects(:styles).returns({}) interpolations = Paperclip::Interpolations interpolations.expects(:extension).returns('jpe') assert_equal "jpe", interpolations.content_type_extension(attachment, :style) end - should "return the latter half of the content type of the extension if no match found" do + it "returns the extension of the file with a dot" do + attachment = mock + attachment.expects(:original_filename).returns("one.jpg") + attachment.expects(:styles).returns({}) + assert_equal ".jpg", Paperclip::Interpolations.dotextension(attachment, :style) + end + + it "returns the extension of the file without a dot if the extension is empty" do + attachment = mock + attachment.expects(:original_filename).returns("one") + attachment.expects(:styles).returns({}) + assert_equal "", Paperclip::Interpolations.dotextension(attachment, :style) + end + + it "returns the latter half of the content type of the extension if no match found" do attachment = mock attachment.expects(:content_type).at_least_once().returns('not/found') + attachment.expects(:styles).returns({}) interpolations = Paperclip::Interpolations interpolations.expects(:extension).returns('random') assert_equal "found", interpolations.content_type_extension(attachment, :style) end - should "return the #to_param of the attachment" do + it "returns the format if defined in the style, ignoring the content type" do + attachment = mock + attachment.expects(:content_type).returns('image/jpeg') + attachment.expects(:styles).returns({style: {format: "png"}}) + interpolations = Paperclip::Interpolations + interpolations.expects(:extension).returns('random') + assert_equal "png", interpolations.content_type_extension(attachment, :style) + end + + it "is able to handle numeric style names" do + attachment = mock( + styles: {:"4" => {format: :expected_extension}} + ) + assert_equal :expected_extension, Paperclip::Interpolations.extension(attachment, 4) + end + + it "returns the #to_param of the attachment" do attachment = mock attachment.expects(:to_param).returns("23-awesome") attachment.expects(:instance).returns(attachment) assert_equal "23-awesome", Paperclip::Interpolations.param(attachment, :style) end - should "return the id of the attachment" do + it "returns the id of the attachment" do attachment = mock attachment.expects(:id).returns(23) attachment.expects(:instance).returns(attachment) assert_equal 23, Paperclip::Interpolations.id(attachment, :style) end - should "return nil for attachments to new records" do + it "returns nil for attachments to new records" do attachment = mock attachment.expects(:id).returns(nil) attachment.expects(:instance).returns(attachment) assert_nil Paperclip::Interpolations.id(attachment, :style) end - should "return the partitioned id of the attachment when the id is an integer" do + it "returns the partitioned id of the attachment when the id is an integer" do attachment = mock attachment.expects(:id).returns(23) attachment.expects(:instance).returns(attachment) assert_equal "000/000/023", Paperclip::Interpolations.id_partition(attachment, :style) end - should "return the partitioned id of the attachment when the id is a string" do + it "returns the partitioned id when the id is above 999_999_999" do + attachment = mock + attachment.expects(:id). + returns(Paperclip::Interpolations::ID_PARTITION_LIMIT) + attachment.expects(:instance).returns(attachment) + assert_equal "001/000/000/000", + Paperclip::Interpolations.id_partition(attachment, :style) + end + + it "returns the partitioned id of the attachment when the id is a string" do attachment = mock attachment.expects(:id).returns("32fnj23oio2f") attachment.expects(:instance).returns(attachment) assert_equal "32f/nj2/3oi", Paperclip::Interpolations.id_partition(attachment, :style) end - should "return nil for the partitioned id of an attachment to a new record (when the id is nil)" do + it "returns nil for the partitioned id of an attachment to a new record (when the id is nil)" do attachment = mock attachment.expects(:id).returns(nil) attachment.expects(:instance).returns(attachment) assert_nil Paperclip::Interpolations.id_partition(attachment, :style) end - should "return the name of the attachment" do + it "returns the name of the attachment" do attachment = mock attachment.expects(:name).returns("file") assert_equal "files", Paperclip::Interpolations.attachment(attachment, :style) end - should "return the style" do + it "returns the style" do assert_equal :style, Paperclip::Interpolations.style(:attachment, :style) end - should "return the default style" do + it "returns the default style" do attachment = mock attachment.expects(:default_style).returns(:default_style) assert_equal :default_style, Paperclip::Interpolations.style(attachment, nil) end - should "reinterpolate :url" do + it "reinterpolates :url" do attachment = mock - attachment.expects(:url).with(:style, :timestamp => false, :escape => false).returns("1234") + attachment.expects(:url).with(:style, timestamp: false, escape: false).returns("1234") assert_equal "1234", Paperclip::Interpolations.url(attachment, :style) end - should "raise if infinite loop detcted reinterpolating :url" do + it "raises if infinite loop detcted reinterpolating :url" do attachment = Object.new class << attachment def url(*args) Paperclip::Interpolations.url(self, :style) end end - assert_raises(Paperclip::InfiniteInterpolationError){ Paperclip::Interpolations.url(attachment, :style) } + assert_raises(Paperclip::Errors::InfiniteInterpolationError){ Paperclip::Interpolations.url(attachment, :style) } end - should "return the filename as basename.extension" do + it "returns the filename as basename.extension" do attachment = mock attachment.expects(:styles).returns({}) - attachment.expects(:original_filename).returns("one.jpg").times(3) + attachment.expects(:original_filename).returns("one.jpg").times(2) assert_equal "one.jpg", Paperclip::Interpolations.filename(attachment, :style) end - should "return the filename as basename.extension when format supplied" do + it "returns the filename as basename.extension when format supplied" do attachment = mock - attachment.expects(:styles).returns({:style => {:format => :png}}) - attachment.expects(:original_filename).returns("one.jpg").times(2) + attachment.expects(:styles).returns({style: {format: :png}}) + attachment.expects(:original_filename).returns("one.jpg").times(1) assert_equal "one.png", Paperclip::Interpolations.filename(attachment, :style) end - should "return the filename as basename when extension is blank" do + it "returns the filename as basename when extension is blank" do attachment = mock attachment.stubs(:styles).returns({}) attachment.stubs(:original_filename).returns("one") assert_equal "one", Paperclip::Interpolations.filename(attachment, :style) end - should "return the basename when the extension contains regexp special characters" do + it "returns the basename when the extension contains regexp special characters" do attachment = mock attachment.stubs(:styles).returns({}) attachment.stubs(:original_filename).returns("one.ab)") assert_equal "one", Paperclip::Interpolations.basename(attachment, :style) end - should "return the timestamp" do + it "returns the timestamp" do now = Time.now zone = 'UTC' attachment = mock @@ -188,32 +231,41 @@ def url(*args) assert_equal now.in_time_zone(zone).to_s, Paperclip::Interpolations.timestamp(attachment, :style) end - should "return updated_at" do + it "returns updated_at" do attachment = mock seconds_since_epoch = 1234567890 attachment.expects(:updated_at).returns(seconds_since_epoch) assert_equal seconds_since_epoch, Paperclip::Interpolations.updated_at(attachment, :style) end - should "return attachment's hash when passing both arguments" do + it "returns attachment's hash when passing both arguments" do attachment = mock fake_hash = "a_wicked_secure_hash" attachment.expects(:hash_key).returns(fake_hash) assert_equal fake_hash, Paperclip::Interpolations.hash(attachment, :style) end - should "return Object#hash when passing no argument" do + it "returns Object#hash when passing no argument" do attachment = mock fake_hash = "a_wicked_secure_hash" attachment.expects(:hash_key).never.returns(fake_hash) assert_not_equal fake_hash, Paperclip::Interpolations.hash end - should "call all expected interpolations with the given arguments" do + it "calls all expected interpolations with the given arguments" do Paperclip::Interpolations.expects(:id).with(:attachment, :style).returns(1234) Paperclip::Interpolations.expects(:attachment).with(:attachment, :style).returns("attachments") Paperclip::Interpolations.expects(:notreal).never value = Paperclip::Interpolations.interpolate(":notreal/:id/:attachment", :attachment, :style) assert_equal ":notreal/1234/attachments", value end + + it "handles question marks" do + Paperclip.interpolates :foo? do + "bar" + end + Paperclip::Interpolations.expects(:fool).never + value = Paperclip::Interpolations.interpolate(":fo/:foo?") + assert_equal ":fo/bar", value + end end diff --git a/spec/paperclip/io_adapters/abstract_adapter_spec.rb b/spec/paperclip/io_adapters/abstract_adapter_spec.rb new file mode 100644 index 000000000..00956d2c7 --- /dev/null +++ b/spec/paperclip/io_adapters/abstract_adapter_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +describe Paperclip::AbstractAdapter do + class TestAdapter < Paperclip::AbstractAdapter + attr_accessor :tempfile + + def content_type + Paperclip::ContentTypeDetector.new(path).detect + end + end + + subject { TestAdapter.new(nil) } + + context "content type from file contents" do + before do + subject.stubs(:path).returns("image.png") + Paperclip.stubs(:run).returns("image/png\n") + Paperclip::ContentTypeDetector.any_instance.stubs(:type_from_mime_magic).returns("image/png") + end + + it "returns the content type without newline" do + assert_equal "image/png", subject.content_type + end + end + + context "nil?" do + it "returns false" do + assert !subject.nil? + end + end + + context "delegation" do + before do + subject.tempfile = stub("Tempfile") + end + + [:binmode, :binmode?, :close, :close!, :closed?, :eof?, :path, :readbyte, :rewind, :unlink].each do |method| + it "delegates #{method} to @tempfile" do + subject.tempfile.stubs(method) + subject.public_send(method) + assert_received subject.tempfile, method + end + end + end + + it 'gets rid of slashes and colons in filenames' do + subject.original_filename = "awesome/file:name.png" + + assert_equal "awesome_file_name.png", subject.original_filename + end + + it 'is an assignment' do + assert subject.assignment? + end + + it 'is not nil' do + assert !subject.nil? + end + + it "generates a destination filename with no original filename" do + expect(subject.send(:destination).path).to_not be_nil + end + + it 'uses the original filename to generate the tempfile' do + subject.original_filename = "file.png" + expect(subject.send(:destination).path).to end_with(".png") + end + + context "generates a fingerprint" do + subject { TestAdapter.new(nil, options) } + + before do + subject.stubs(:path).returns(fixture_file("50x50.png")) + end + + context "MD5" do + let(:options) { { hash_digest: Digest::MD5 } } + + it "returns a fingerprint" do + expect(subject.fingerprint).to be_a String + expect(subject.fingerprint).to eq "a790b00c9b5d58a8fd17a1ec5a187129" + end + end + + context "SHA256" do + let(:options) { { hash_digest: Digest::SHA256 } } + + it "returns a fingerprint" do + expect(subject.fingerprint).to be_a String + expect(subject.fingerprint). + to eq "243d7ce1099719df25f600f1c369c629fb979f88d5a01dbe7d0d48c8e6715bb1" + end + end + end + + context "#copy_to_tempfile" do + around do |example| + FileUtils.module_eval do + class << self + alias paperclip_ln ln + + def ln(*) + raise Errno::EXDEV + end + end + end + + example.run + + FileUtils.module_eval do + class << self + alias ln paperclip_ln + undef paperclip_ln + end + end + end + + it "should return a readable file even when linking fails" do + src = open(fixture_file("5k.png"), "rb") + expect(subject.send(:copy_to_tempfile, src).read).to eq src.read + end + end + + context "#original_filename=" do + it "should not fail with a nil original filename" do + expect { subject.original_filename = nil }.not_to raise_error + end + end + + context "#link_or_copy_file" do + class TestLinkOrCopyAdapter < Paperclip::AbstractAdapter + public :copy_to_tempfile, :destination + end + + subject { TestLinkOrCopyAdapter.new(nil) } + let(:body) { "body" } + + let(:file) do + t = Tempfile.new("destination") + t.print(body) + t.rewind + t + end + + after do + file.close + file.unlink + end + + it "should be able to read the file" do + expect(subject.copy_to_tempfile(file).read).to eq(body) + end + + it "should be able to reopen the file after symlink has failed" do + FileUtils.expects(:ln).raises(Errno::EXDEV) + + expect(subject.copy_to_tempfile(file).read).to eq(body) + end + end +end diff --git a/spec/paperclip/io_adapters/attachment_adapter_spec.rb b/spec/paperclip/io_adapters/attachment_adapter_spec.rb new file mode 100644 index 000000000..5eaeee186 --- /dev/null +++ b/spec/paperclip/io_adapters/attachment_adapter_spec.rb @@ -0,0 +1,142 @@ +require 'spec_helper' + +describe Paperclip::AttachmentAdapter do + before do + rebuild_model path: "tmp/:class/:attachment/:style/:filename", styles: {thumb: '50x50'} + @attachment = Dummy.new.avatar + end + + context "for an attachment" do + before do + @file = File.new(fixture_file("5k.png")) + @file.binmode + + @attachment.assign(@file) + @attachment.save + @subject = Paperclip.io_adapters.for(@attachment, + hash_digest: Digest::MD5) + end + + after do + @file.close + @subject.close + end + + it "gets the right filename" do + assert_equal "5k.png", @subject.original_filename + end + + it "forces binmode on tempfile" do + assert @subject.instance_variable_get("@tempfile").binmode? + end + + it "gets the content type" do + assert_equal "image/png", @subject.content_type + end + + it "gets the file's size" do + assert_equal 4456, @subject.size + end + + it "returns false for a call to nil?" do + assert ! @subject.nil? + end + + it "generates a MD5 hash of the contents" do + expected = Digest::MD5.file(@file.path).to_s + assert_equal expected, @subject.fingerprint + end + + it "reads the contents of the file" do + expected = @file.read + actual = @subject.read + assert expected.length > 0 + assert_equal expected.length, actual.length + assert_equal expected, actual + end + + end + + context "for a file with restricted characters in the name" do + before do + file_contents = IO.read(fixture_file("animated.gif")) + @file = StringIO.new(file_contents) + @file.stubs(:original_filename).returns('image:restricted.gif') + @file.binmode + + @attachment.assign(@file) + @attachment.save + @subject = Paperclip.io_adapters.for(@attachment, + hash_digest: Digest::MD5) + end + + after do + @subject.close + end + + it "does not generate paths that include restricted characters" do + expect(@subject.path).to_not match(/:/) + end + + it "does not generate filenames that include restricted characters" do + assert_equal 'image_restricted.gif', @subject.original_filename + end + end + + context "for a style" do + before do + @file = File.new(fixture_file("5k.png")) + @file.binmode + + @attachment.assign(@file) + + @thumb = Tempfile.new("thumbnail").tap(&:binmode) + FileUtils.cp @attachment.queued_for_write[:thumb].path, @thumb.path + + @attachment.save + @subject = Paperclip.io_adapters.for(@attachment.styles[:thumb], + hash_digest: Digest::MD5) + end + + after do + @file.close + @thumb.close + @subject.close + end + + it "gets the original filename" do + assert_equal "5k.png", @subject.original_filename + end + + it "forces binmode on tempfile" do + assert @subject.instance_variable_get("@tempfile").binmode? + end + + it "gets the content type" do + assert_equal "image/png", @subject.content_type + end + + it "gets the thumbnail's file size" do + assert_equal @thumb.size, @subject.size + end + + it "returns false for a call to nil?" do + assert ! @subject.nil? + end + + it "generates a MD5 hash of the contents" do + expected = Digest::MD5.file(@thumb.path).to_s + assert_equal expected, @subject.fingerprint + end + + it "reads the contents of the thumbnail" do + @thumb.rewind + expected = @thumb.read + actual = @subject.read + assert expected.length > 0 + assert_equal expected.length, actual.length + assert_equal expected, actual + end + + end +end diff --git a/spec/paperclip/io_adapters/data_uri_adapter_spec.rb b/spec/paperclip/io_adapters/data_uri_adapter_spec.rb new file mode 100644 index 000000000..e8fe4815d --- /dev/null +++ b/spec/paperclip/io_adapters/data_uri_adapter_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe Paperclip::DataUriAdapter do + before do + Paperclip::DataUriAdapter.register + end + + after do + Paperclip.io_adapters.unregister(described_class) + + if @subject + @subject.close + end + end + + it 'allows a missing mime-type' do + adapter = Paperclip.io_adapters.for("data:;base64,#{original_base64_content}") + assert_equal Paperclip::DataUriAdapter, adapter.class + end + + it 'alows mime type that has dot in it' do + adapter = Paperclip.io_adapters.for("data:image/vnd.microsoft.icon;base64,#{original_base64_content}") + assert_equal Paperclip::DataUriAdapter, adapter.class + end + + context "a new instance" do + before do + @contents = "data:image/png;base64,#{original_base64_content}" + @subject = Paperclip.io_adapters.for(@contents, hash_digest: Digest::MD5) + end + + it "returns a nondescript file name" do + assert_equal "data", @subject.original_filename + end + + it "returns a content type" do + assert_equal "image/png", @subject.content_type + end + + it "returns the size of the data" do + assert_equal 4456, @subject.size + end + + it "generates a correct MD5 hash of the contents" do + assert_equal( + Digest::MD5.hexdigest(Base64.decode64(original_base64_content)), + @subject.fingerprint + ) + end + + it "generates correct fingerprint after read" do + fingerprint = Digest::MD5.hexdigest(@subject.read) + assert_equal fingerprint, @subject.fingerprint + end + + it "generates same fingerprint" do + assert_equal @subject.fingerprint, @subject.fingerprint + end + + it 'accepts a content_type' do + @subject.content_type = 'image/png' + assert_equal 'image/png', @subject.content_type + end + + it 'accepts an original_filename' do + @subject.original_filename = 'image.png' + assert_equal 'image.png', @subject.original_filename + end + + it "does not generate filenames that include restricted characters" do + @subject.original_filename = 'image:restricted.png' + assert_equal 'image_restricted.png', @subject.original_filename + end + + it "does not generate paths that include restricted characters" do + @subject.original_filename = 'image:restricted.png' + expect(@subject.path).to_not match(/:/) + end + + end + + def original_base64_content + Base64.encode64(original_file_contents) + end + + def original_file_contents + @original_file_contents ||= File.read(fixture_file('5k.png')) + end +end diff --git a/spec/paperclip/io_adapters/empty_string_adapter_spec.rb b/spec/paperclip/io_adapters/empty_string_adapter_spec.rb new file mode 100644 index 000000000..11822e650 --- /dev/null +++ b/spec/paperclip/io_adapters/empty_string_adapter_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Paperclip::EmptyStringAdapter do + context 'a new instance' do + before do + @subject = Paperclip.io_adapters.for('') + end + + it "returns false for a call to nil?" do + assert !@subject.nil? + end + + it 'returns false for a call to assignment?' do + assert !@subject.assignment? + end + end +end diff --git a/spec/paperclip/io_adapters/file_adapter_spec.rb b/spec/paperclip/io_adapters/file_adapter_spec.rb new file mode 100644 index 000000000..8a2a5e8d5 --- /dev/null +++ b/spec/paperclip/io_adapters/file_adapter_spec.rb @@ -0,0 +1,131 @@ +require 'spec_helper' + +describe Paperclip::FileAdapter do + context "a new instance" do + context "with normal file" do + before do + @file = File.new(fixture_file("5k.png")) + @file.binmode + end + + after do + @file.close + @subject.close if @subject + end + + context 'doing normal things' do + before do + @subject = Paperclip.io_adapters.for(@file, hash_digest: Digest::MD5) + end + + it 'uses the original filename to generate the tempfile' do + assert @subject.path.ends_with?(".png") + end + + it "gets the right filename" do + assert_equal "5k.png", @subject.original_filename + end + + it "forces binmode on tempfile" do + assert @subject.instance_variable_get("@tempfile").binmode? + end + + it "gets the content type" do + assert_equal "image/png", @subject.content_type + end + + it "returns content type as a string" do + expect(@subject.content_type).to be_a String + end + + it "gets the file's size" do + assert_equal 4456, @subject.size + end + + it "returns false for a call to nil?" do + assert ! @subject.nil? + end + + it "generates a MD5 hash of the contents" do + expected = Digest::MD5.file(@file.path).to_s + assert_equal expected, @subject.fingerprint + end + + it "reads the contents of the file" do + expected = @file.read + assert expected.length > 0 + assert_equal expected, @subject.read + end + end + + context "file with multiple possible content type" do + before do + MIME::Types.stubs(:type_for).returns([MIME::Type.new('image/x-png'), MIME::Type.new('image/png')]) + @subject = Paperclip.io_adapters.for(@file, hash_digest: Digest::MD5) + end + + it "prefers officially registered mime type" do + assert_equal "image/png", @subject.content_type + end + + it "returns content type as a string" do + expect(@subject.content_type).to be_a String + end + end + + context "file with content type derived from file contents on *nix" do + before do + MIME::Types.stubs(:type_for).returns([]) + Paperclip.stubs(:run).returns("application/vnd.ms-office\n") + Paperclip::ContentTypeDetector.any_instance + .stubs(:type_from_mime_magic).returns("application/vnd.ms-office") + + @subject = Paperclip.io_adapters.for(@file) + end + + it "returns content type without newline character" do + assert_equal "application/vnd.ms-office", @subject.content_type + end + end + end + + context "filename with restricted characters" do + before do + @file = File.open(fixture_file("animated.gif")) do |file| + StringIO.new(file.read) + end + @file.stubs(:original_filename).returns('image:restricted.gif') + @subject = Paperclip.io_adapters.for(@file) + end + + after do + @file.close + @subject.close + end + + it "does not generate filenames that include restricted characters" do + assert_equal 'image_restricted.gif', @subject.original_filename + end + + it "does not generate paths that include restricted characters" do + expect(@subject.path).to_not match(/:/) + end + end + + context "empty file" do + before do + @file = Tempfile.new("file_adapter_test") + @subject = Paperclip.io_adapters.for(@file) + end + + after do + @file.close + @subject.close + end + + it "provides correct mime-type" do + assert_match %r{.*/x-empty}, @subject.content_type + end + end + end +end diff --git a/spec/paperclip/io_adapters/http_url_proxy_adapter_spec.rb b/spec/paperclip/io_adapters/http_url_proxy_adapter_spec.rb new file mode 100644 index 000000000..6d674db5e --- /dev/null +++ b/spec/paperclip/io_adapters/http_url_proxy_adapter_spec.rb @@ -0,0 +1,138 @@ +require 'spec_helper' + +describe Paperclip::HttpUrlProxyAdapter do + before do + @open_return = StringIO.new("xxx") + @open_return.stubs(:meta).returns("content-type" => "image/png") + Paperclip::HttpUrlProxyAdapter.any_instance.stubs(:download_content). + returns(@open_return) + Paperclip::HttpUrlProxyAdapter.register + end + + after do + Paperclip.io_adapters.unregister(described_class) + end + + context "a new instance" do + before do + @url = "http://thoughtbot.com/images/thoughtbot-logo.png" + @subject = Paperclip.io_adapters.for(@url, hash_digest: Digest::MD5) + end + + after do + @subject.close + end + + it "returns a file name" do + assert_equal "thoughtbot-logo.png", @subject.original_filename + end + + it 'closes open handle after reading' do + assert_equal true, @open_return.closed? + end + + it "returns a content type" do + assert_equal "image/png", @subject.content_type + end + + it "returns the size of the data" do + assert_equal @open_return.size, @subject.size + end + + it "generates an MD5 hash of the contents" do + assert_equal Digest::MD5.hexdigest("xxx"), @subject.fingerprint + end + + it "generates correct fingerprint after read" do + fingerprint = Digest::MD5.hexdigest(@subject.read) + assert_equal fingerprint, @subject.fingerprint + end + + it "generates same fingerprint" do + assert_equal @subject.fingerprint, @subject.fingerprint + end + + it "returns the data contained in the StringIO" do + assert_equal "xxx", @subject.read + end + + it 'accepts a content_type' do + @subject.content_type = 'image/png' + assert_equal 'image/png', @subject.content_type + end + + it 'accepts an original_filename' do + @subject.original_filename = 'image.png' + assert_equal 'image.png', @subject.original_filename + end + end + + context "a url with query params" do + subject { Paperclip.io_adapters.for(url) } + + after { subject.close } + + let(:url) { "https://github.com/thoughtbot/paperclip?file=test" } + + it "returns a file name" do + assert_equal "paperclip", subject.original_filename + end + + it "preserves params" do + assert_equal url, subject.instance_variable_get(:@target).to_s + end + end + + context "a url with restricted characters in the filename" do + before do + @url = "https://github.com/thoughtbot/paper:clip.jpg" + @subject = Paperclip.io_adapters.for(@url) + end + + after do + begin + @subject.close + rescue Exception + true + end + end + + it "does not generate filenames that include restricted characters" do + assert_equal "paper_clip.jpg", @subject.original_filename + end + + it "does not generate paths that include restricted characters" do + expect(@subject.path).to_not match(/:/) + end + end + + context "a url with special characters in the filename" do + before do + Paperclip::HttpUrlProxyAdapter.any_instance.stubs(:download_content). + returns(@open_return) + end + + let(:filename) do + "paperclip-%C3%B6%C3%A4%C3%BC%E5%AD%97%C2%B4%C2%BD%E2%99%A5"\ + "%C3%98%C2%B2%C3%88.png" + end + let(:url) { "https://github.com/thoughtbot/paperclip-öäü字´½♥زÈ.png" } + + subject { Paperclip.io_adapters.for(url) } + + it "returns a encoded filename" do + assert_equal filename, subject.original_filename + end + + context "when already URI encoded" do + let(:url) do + "https://github.com/thoughtbot/paperclip-%C3%B6%C3%A4%C3%BC%E5%AD%97"\ + "%C2%B4%C2%BD%E2%99%A5%C3%98%C2%B2%C3%88.png" + end + + it "returns a encoded filename" do + assert_equal filename, subject.original_filename + end + end + end +end diff --git a/spec/paperclip/io_adapters/identity_adapter_spec.rb b/spec/paperclip/io_adapters/identity_adapter_spec.rb new file mode 100644 index 000000000..1ee13e252 --- /dev/null +++ b/spec/paperclip/io_adapters/identity_adapter_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' + +describe Paperclip::IdentityAdapter do + it "responds to #new by returning the argument" do + adapter = Paperclip::IdentityAdapter.new + assert_equal :target, adapter.new(:target, nil) + end +end diff --git a/spec/paperclip/io_adapters/nil_adapter_spec.rb b/spec/paperclip/io_adapters/nil_adapter_spec.rb new file mode 100644 index 000000000..21fd5fd33 --- /dev/null +++ b/spec/paperclip/io_adapters/nil_adapter_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Paperclip::NilAdapter do + context 'a new instance' do + before do + @subject = Paperclip.io_adapters.for(nil) + end + + it "gets the right filename" do + assert_equal "", @subject.original_filename + end + + it "gets the content type" do + assert_equal "", @subject.content_type + end + + it "gets the file's size" do + assert_equal 0, @subject.size + end + + it "returns true for a call to nil?" do + assert @subject.nil? + end + end +end diff --git a/spec/paperclip/io_adapters/registry_spec.rb b/spec/paperclip/io_adapters/registry_spec.rb new file mode 100644 index 000000000..7b07088e8 --- /dev/null +++ b/spec/paperclip/io_adapters/registry_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Paperclip::AttachmentRegistry do + context "for" do + before do + class AdapterTest + def initialize(_target, _ = {}); end + end + @subject = Paperclip::AdapterRegistry.new + @subject.register(AdapterTest){|t| Symbol === t } + end + + it "returns the class registered for the adapted type" do + assert_equal AdapterTest, @subject.for(:target).class + end + end + + context "registered?" do + before do + class AdapterTest + def initialize(_target, _ = {}); end + end + @subject = Paperclip::AdapterRegistry.new + @subject.register(AdapterTest){|t| Symbol === t } + end + + it "returns true when the class of this adapter has been registered" do + assert @subject.registered?(AdapterTest.new(:target)) + end + + it "returns false when the adapter has not been registered" do + assert ! @subject.registered?(Object) + end + end +end diff --git a/spec/paperclip/io_adapters/stringio_adapter_spec.rb b/spec/paperclip/io_adapters/stringio_adapter_spec.rb new file mode 100644 index 000000000..14278a8f7 --- /dev/null +++ b/spec/paperclip/io_adapters/stringio_adapter_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe Paperclip::StringioAdapter do + context "a new instance" do + before do + @contents = "abc123" + @stringio = StringIO.new(@contents) + @subject = Paperclip.io_adapters.for(@stringio, hash_digest: Digest::MD5) + end + + it "returns a file name" do + assert_equal "data", @subject.original_filename + end + + it "returns a content type" do + assert_equal "text/plain", @subject.content_type + end + + it "returns the size of the data" do + assert_equal 6, @subject.size + end + + it "returns the length of the data" do + assert_equal 6, @subject.length + end + + it "generates an MD5 hash of the contents" do + assert_equal Digest::MD5.hexdigest(@contents), @subject.fingerprint + end + + it "generates correct fingerprint after read" do + fingerprint = Digest::MD5.hexdigest(@subject.read) + assert_equal fingerprint, @subject.fingerprint + end + + it "generates same fingerprint" do + assert_equal @subject.fingerprint, @subject.fingerprint + end + + it "returns the data contained in the StringIO" do + assert_equal "abc123", @subject.read + end + + it 'accepts a content_type' do + @subject.content_type = 'image/png' + assert_equal 'image/png', @subject.content_type + end + + it 'accepts an original_filename' do + @subject.original_filename = 'image.png' + assert_equal 'image.png', @subject.original_filename + end + + it "does not generate filenames that include restricted characters" do + @subject.original_filename = 'image:restricted.png' + assert_equal 'image_restricted.png', @subject.original_filename + end + + it "does not generate paths that include restricted characters" do + @subject.original_filename = 'image:restricted.png' + expect(@subject.path).to_not match(/:/) + end + end +end diff --git a/spec/paperclip/io_adapters/uploaded_file_adapter_spec.rb b/spec/paperclip/io_adapters/uploaded_file_adapter_spec.rb new file mode 100644 index 000000000..162d5d72f --- /dev/null +++ b/spec/paperclip/io_adapters/uploaded_file_adapter_spec.rb @@ -0,0 +1,146 @@ +require 'spec_helper' + +describe Paperclip::UploadedFileAdapter do + context "a new instance" do + context "with UploadedFile responding to #tempfile" do + before do + Paperclip::UploadedFileAdapter.content_type_detector = nil + + class UploadedFile < OpenStruct; end + tempfile = File.new(fixture_file("5k.png")) + tempfile.binmode + + @file = UploadedFile.new( + original_filename: "5k.png", + content_type: "image/x-png-by-browser\r", + head: "", + tempfile: tempfile, + path: tempfile.path + ) + @subject = Paperclip.io_adapters.for(@file, hash_digest: Digest::MD5) + end + + it "gets the right filename" do + assert_equal "5k.png", @subject.original_filename + end + + it "forces binmode on tempfile" do + assert @subject.instance_variable_get("@tempfile").binmode? + end + + it "gets the content type" do + assert_equal "image/png", @subject.content_type + end + + it "gets the file's size" do + assert_equal 4456, @subject.size + end + + it "returns false for a call to nil?" do + assert ! @subject.nil? + end + + it "generates a MD5 hash of the contents" do + expected = Digest::MD5.file(@file.tempfile.path).to_s + assert_equal expected, @subject.fingerprint + end + + it "reads the contents of the file" do + expected = @file.tempfile.read + assert expected.length > 0 + assert_equal expected, @subject.read + end + end + + context "with UploadedFile that has restricted characters" do + before do + Paperclip::UploadedFileAdapter.content_type_detector = nil + + class UploadedFile < OpenStruct; end + @file = UploadedFile.new( + original_filename: "image:restricted.gif", + content_type: "image/x-png-by-browser", + head: "", + path: fixture_file("5k.png") + ) + @subject = Paperclip.io_adapters.for(@file, hash_digest: Digest::MD5) + end + + it "does not generate paths that include restricted characters" do + expect(@subject.path).to_not match(/:/) + end + + it "does not generate filenames that include restricted characters" do + assert_equal 'image_restricted.gif', @subject.original_filename + end + end + + context "with UploadFile responding to #path" do + before do + Paperclip::UploadedFileAdapter.content_type_detector = nil + + class UploadedFile < OpenStruct; end + @file = UploadedFile.new( + original_filename: "5k.png", + content_type: "image/x-png-by-browser", + head: "", + path: fixture_file("5k.png") + ) + @subject = Paperclip.io_adapters.for(@file, hash_digest: Digest::MD5) + end + + it "gets the right filename" do + assert_equal "5k.png", @subject.original_filename + end + + it "forces binmode on tempfile" do + assert @subject.instance_variable_get("@tempfile").binmode? + end + + it "gets the content type" do + assert_equal "image/png", @subject.content_type + end + + it "gets the file's size" do + assert_equal 4456, @subject.size + end + + it "returns false for a call to nil?" do + assert ! @subject.nil? + end + + it "generates a MD5 hash of the contents" do + expected = Digest::MD5.file(@file.path).to_s + assert_equal expected, @subject.fingerprint + end + + it "reads the contents of the file" do + expected_file = File.new(@file.path) + expected_file.binmode + expected = expected_file.read + assert expected.length > 0 + assert_equal expected, @subject.read + end + + context "don't trust client-given MIME type" do + before do + Paperclip::UploadedFileAdapter.content_type_detector = + Paperclip::FileCommandContentTypeDetector + + class UploadedFile < OpenStruct; end + @file = UploadedFile.new( + original_filename: "5k.png", + content_type: "image/x-png-by-browser", + head: "", + path: fixture_file("5k.png") + ) + @subject = Paperclip.io_adapters.for(@file) + end + + it "gets the content type" do + assert_equal "image/png", @subject.content_type + end + end + end + end +end diff --git a/spec/paperclip/io_adapters/uri_adapter_spec.rb b/spec/paperclip/io_adapters/uri_adapter_spec.rb new file mode 100644 index 000000000..827da5959 --- /dev/null +++ b/spec/paperclip/io_adapters/uri_adapter_spec.rb @@ -0,0 +1,220 @@ +require 'spec_helper' + +describe Paperclip::UriAdapter do + let(:content_type) { "image/png" } + let(:meta) { {} } + + before do + @open_return = StringIO.new("xxx") + @open_return.stubs(:content_type).returns(content_type) + @open_return.stubs(:meta).returns(meta) + Paperclip::UriAdapter.register + end + + after do + Paperclip.io_adapters.unregister(described_class) + end + + context "a new instance" do + let(:meta) { { "content-type" => "image/png" } } + + before do + Paperclip::UriAdapter.any_instance. + stubs(:download_content).returns(@open_return) + + @uri = URI.parse("http://thoughtbot.com/images/thoughtbot-logo.png") + @subject = Paperclip.io_adapters.for(@uri, hash_digest: Digest::MD5) + end + + it "returns a file name" do + assert_equal "thoughtbot-logo.png", @subject.original_filename + end + + it 'closes open handle after reading' do + assert_equal true, @open_return.closed? + end + + it "returns a content type" do + assert_equal "image/png", @subject.content_type + end + + it "returns the size of the data" do + assert_equal @open_return.size, @subject.size + end + + it "generates an MD5 hash of the contents" do + assert_equal Digest::MD5.hexdigest("xxx"), @subject.fingerprint + end + + it "generates correct fingerprint after read" do + fingerprint = Digest::MD5.hexdigest(@subject.read) + assert_equal fingerprint, @subject.fingerprint + end + + it "generates same fingerprint" do + assert_equal @subject.fingerprint, @subject.fingerprint + end + + it "returns the data contained in the StringIO" do + assert_equal "xxx", @subject.read + end + + it 'accepts a content_type' do + @subject.content_type = 'image/png' + assert_equal 'image/png', @subject.content_type + end + + it "accepts an original_filename" do + @subject.original_filename = 'image.png' + assert_equal 'image.png', @subject.original_filename + end + + end + + context "a directory index url" do + let(:content_type) { "text/html" } + let(:meta) { { "content-type" => "text/html" } } + + before do + Paperclip::UriAdapter.any_instance. + stubs(:download_content).returns(@open_return) + + @uri = URI.parse("http://thoughtbot.com") + @subject = Paperclip.io_adapters.for(@uri) + end + + it "returns a file name" do + assert_equal "index.html", @subject.original_filename + end + + it "returns a content type" do + assert_equal "text/html", @subject.content_type + end + end + + context "a url with query params" do + before do + Paperclip::UriAdapter.any_instance. + stubs(:download_content).returns(@open_return) + + @uri = URI.parse("https://github.com/thoughtbot/paperclip?file=test") + @subject = Paperclip.io_adapters.for(@uri) + end + + it "returns a file name" do + assert_equal "paperclip", @subject.original_filename + end + end + + context "a url with content disposition headers" do + let(:file_name) { "test_document.pdf" } + let(:filename_from_path) { "paperclip" } + + before do + Paperclip::UriAdapter.any_instance. + stubs(:download_content).returns(@open_return) + + @uri = URI.parse( + "https://github.com/thoughtbot/#{filename_from_path}?file=test") + end + + it "returns file name from path" do + meta["content-disposition"] = "inline;" + + @subject = Paperclip.io_adapters.for(@uri) + + assert_equal filename_from_path, @subject.original_filename + end + + it "returns a file name enclosed in double quotes" do + file_name = "john's test document.pdf" + meta["content-disposition"] = "attachment; filename=\"#{file_name}\";" + + @subject = Paperclip.io_adapters.for(@uri) + + assert_equal file_name, @subject.original_filename + end + + it "returns a file name not enclosed in double quotes" do + meta["content-disposition"] = "ATTACHMENT; FILENAME=#{file_name};" + + @subject = Paperclip.io_adapters.for(@uri) + + assert_equal file_name, @subject.original_filename + end + + it "does not crash when an empty filename is given" do + meta["content-disposition"] = "ATTACHMENT; FILENAME=\"\";" + + @subject = Paperclip.io_adapters.for(@uri) + + assert_equal "", @subject.original_filename + end + + it "returns a file name ignoring RFC 5987 encoding" do + meta["content-disposition"] = + "attachment; filename=#{file_name}; filename* = utf-8''%e2%82%ac%20rates" + + @subject = Paperclip.io_adapters.for(@uri) + + assert_equal file_name, @subject.original_filename + end + + context "when file name has consecutive periods" do + let(:file_name) { "test_document..pdf" } + + it "returns a file name" do + @uri = URI.parse( + "https://github.com/thoughtbot/#{file_name}?file=test") + @subject = Paperclip.io_adapters.for(@uri) + assert_equal file_name, @subject.original_filename + end + end + end + + context "a url with restricted characters in the filename" do + before do + Paperclip::UriAdapter.any_instance. + stubs(:download_content).returns(@open_return) + + @uri = URI.parse("https://github.com/thoughtbot/paper:clip.jpg") + @subject = Paperclip.io_adapters.for(@uri) + end + + it "does not generate filenames that include restricted characters" do + assert_equal "paper_clip.jpg", @subject.original_filename + end + + it "does not generate paths that include restricted characters" do + expect(@subject.path).to_not match(/:/) + end + end + + describe "#download_content" do + before do + Paperclip::UriAdapter.any_instance.stubs(:open).returns(@open_return) + @uri = URI.parse("https://github.com/thoughtbot/paper:clip.jpg") + @subject = Paperclip.io_adapters.for(@uri) + end + + after do + @subject.send(:download_content) + end + + context "with default read_timeout" do + it "calls open without options" do + @subject.expects(:open).with(@uri, {}).at_least_once + end + end + + context "with custom read_timeout" do + before do + Paperclip.options[:read_timeout] = 120 + end + + it "calls open with read_timeout option" do + @subject.expects(:open).with(@uri, read_timeout: 120).at_least_once + end + end + end +end diff --git a/spec/paperclip/matchers/have_attached_file_matcher_spec.rb b/spec/paperclip/matchers/have_attached_file_matcher_spec.rb new file mode 100644 index 000000000..170f5da7a --- /dev/null +++ b/spec/paperclip/matchers/have_attached_file_matcher_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' +require 'paperclip/matchers' + +describe Paperclip::Shoulda::Matchers::HaveAttachedFileMatcher do + extend Paperclip::Shoulda::Matchers + + it "rejects the dummy class if it has no attachment" do + reset_table "dummies" + reset_class "Dummy" + matcher = self.class.have_attached_file(:avatar) + expect(matcher).to_not accept(Dummy) + end + + it 'accepts the dummy class if it has an attachment' do + rebuild_model + matcher = self.class.have_attached_file(:avatar) + expect(matcher).to accept(Dummy) + end +end diff --git a/spec/paperclip/matchers/validate_attachment_content_type_matcher_spec.rb b/spec/paperclip/matchers/validate_attachment_content_type_matcher_spec.rb new file mode 100644 index 000000000..260bab3f2 --- /dev/null +++ b/spec/paperclip/matchers/validate_attachment_content_type_matcher_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' +require 'paperclip/matchers' + +describe Paperclip::Shoulda::Matchers::ValidateAttachmentContentTypeMatcher do + extend Paperclip::Shoulda::Matchers + + before do + reset_table("dummies") do |d| + d.string :title + d.string :avatar_file_name + d.string :avatar_content_type + end + reset_class "Dummy" + Dummy.do_not_validate_attachment_file_type :avatar + Dummy.has_attached_file :avatar + end + + it "rejects a class with no validation" do + expect(matcher).to_not accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it 'rejects a class when the validation fails' do + Dummy.validates_attachment_content_type :avatar, content_type: %r{audio/.*} + expect(matcher).to_not accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it "accepts a class with a matching validation" do + Dummy.validates_attachment_content_type :avatar, content_type: %r{image/.*} + expect(matcher).to accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it "accepts a class with other validations but matching types" do + Dummy.validates_presence_of :title + Dummy.validates_attachment_content_type :avatar, content_type: %r{image/.*} + expect(matcher).to accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it "accepts a class that matches and a matcher that only specifies 'allowing'" do + Dummy.validates_attachment_content_type :avatar, content_type: %r{image/.*} + matcher = plain_matcher.allowing(%w(image/png image/jpeg)) + + expect(matcher).to accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it "rejects a class that does not match and a matcher that only specifies 'allowing'" do + Dummy.validates_attachment_content_type :avatar, content_type: %r{audio/.*} + matcher = plain_matcher.allowing(%w(image/png image/jpeg)) + + expect(matcher).to_not accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it "accepts a class that matches and a matcher that only specifies 'rejecting'" do + Dummy.validates_attachment_content_type :avatar, content_type: %r{image/.*} + matcher = plain_matcher.rejecting(%w(audio/mp3 application/octet-stream)) + + expect(matcher).to accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it "rejects a class that does not match and a matcher that only specifies 'rejecting'" do + Dummy.validates_attachment_content_type :avatar, content_type: %r{audio/.*} + matcher = plain_matcher.rejecting(%w(audio/mp3 application/octet-stream)) + + expect(matcher).to_not accept(Dummy) + expect { matcher.failure_message }.to_not raise_error + end + + context "using an :if to control the validation" do + before do + Dummy.class_eval do + validates_attachment_content_type :avatar, content_type: %r{image/*} , if: :go + attr_accessor :go + end + end + + it "runs the validation if the control is true" do + dummy = Dummy.new + dummy.go = true + expect(matcher).to accept(dummy) + expect { matcher.failure_message }.to_not raise_error + end + + it "does not run the validation if the control is false" do + dummy = Dummy.new + dummy.go = false + expect(matcher).to_not accept(dummy) + expect { matcher.failure_message }.to_not raise_error + end + end + + private + + def plain_matcher + self.class.validate_attachment_content_type(:avatar) + end + + def matcher + plain_matcher. + allowing(%w(image/png image/jpeg)). + rejecting(%w(audio/mp3 application/octet-stream)) + end + +end diff --git a/spec/paperclip/matchers/validate_attachment_presence_matcher_spec.rb b/spec/paperclip/matchers/validate_attachment_presence_matcher_spec.rb new file mode 100644 index 000000000..7be255faf --- /dev/null +++ b/spec/paperclip/matchers/validate_attachment_presence_matcher_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' +require 'paperclip/matchers' + +describe Paperclip::Shoulda::Matchers::ValidateAttachmentPresenceMatcher do + extend Paperclip::Shoulda::Matchers + + before do + reset_table("dummies") do |d| + d.string :avatar_file_name + end + reset_class "Dummy" + Dummy.has_attached_file :avatar + Dummy.do_not_validate_attachment_file_type :avatar + end + + it "rejects a class with no validation" do + expect(matcher).to_not accept(Dummy) + end + + it "accepts a class with a matching validation" do + Dummy.validates_attachment_presence :avatar + expect(matcher).to accept(Dummy) + end + + it "accepts an instance with other attachment validations" do + reset_table("dummies") do |d| + d.string :avatar_file_name + d.string :avatar_content_type + end + Dummy.class_eval do + validates_attachment_presence :avatar + validates_attachment_content_type :avatar, content_type: 'image/gif' + end + dummy = Dummy.new + + dummy.avatar = File.new fixture_file('5k.png') + + expect(matcher).to accept(dummy) + end + + context "using an :if to control the validation" do + before do + Dummy.class_eval do + validates_attachment_presence :avatar, if: :go + attr_accessor :go + end + end + + it "runs the validation if the control is true" do + dummy = Dummy.new + dummy.avatar = nil + dummy.go = true + expect(matcher).to accept(dummy) + end + + it "does not run the validation if the control is false" do + dummy = Dummy.new + dummy.avatar = nil + dummy.go = false + expect(matcher).to_not accept(dummy) + end + end + + private + + def matcher + self.class.validate_attachment_presence(:avatar) + end +end diff --git a/spec/paperclip/matchers/validate_attachment_size_matcher_spec.rb b/spec/paperclip/matchers/validate_attachment_size_matcher_spec.rb new file mode 100644 index 000000000..688a805ec --- /dev/null +++ b/spec/paperclip/matchers/validate_attachment_size_matcher_spec.rb @@ -0,0 +1,88 @@ +require 'spec_helper' +require 'paperclip/matchers' + +describe Paperclip::Shoulda::Matchers::ValidateAttachmentSizeMatcher do + extend Paperclip::Shoulda::Matchers + + before do + reset_table("dummies") do |d| + d.string :avatar_file_name + d.integer :avatar_file_size + end + reset_class "Dummy" + Dummy.do_not_validate_attachment_file_type :avatar + Dummy.has_attached_file :avatar + end + + context "Limiting size" do + it "rejects a class with no validation" do + expect(matcher.in(256..1024)).to_not accept(Dummy) + end + + it "rejects a class with a validation that's too high" do + Dummy.validates_attachment_size :avatar, in: 256..2048 + expect(matcher.in(256..1024)).to_not accept(Dummy) + end + + it "accepts a class with a validation that's too low" do + Dummy.validates_attachment_size :avatar, in: 0..1024 + expect(matcher.in(256..1024)).to_not accept(Dummy) + end + + it "accepts a class with a validation that matches" do + Dummy.validates_attachment_size :avatar, in: 256..1024 + expect(matcher.in(256..1024)).to accept(Dummy) + end + end + + context "allowing anything" do + it "given a class with an upper limit" do + Dummy.validates_attachment_size :avatar, less_than: 1 + expect(matcher).to accept(Dummy) + end + + it "given a class with a lower limit" do + Dummy.validates_attachment_size :avatar, greater_than: 1 + expect(matcher).to accept(Dummy) + end + end + + context "using an :if to control the validation" do + before do + Dummy.class_eval do + validates_attachment_size :avatar, greater_than: 1024, if: :go + attr_accessor :go + end + end + + it "run the validation if the control is true" do + dummy = Dummy.new + dummy.go = true + expect(matcher.greater_than(1024)).to accept(dummy) + end + + it "not run the validation if the control is false" do + dummy = Dummy.new + dummy.go = false + expect(matcher.greater_than(1024)).to_not accept(dummy) + end + end + + context "post processing" do + before do + Dummy.validates_attachment_size :avatar, greater_than: 1024 + end + + it "be skipped" do + dummy = Dummy.new + dummy.avatar.expects(:post_process).never + expect(matcher.greater_than(1024)).to accept(dummy) + end + end + + private + + def matcher + self.class.validate_attachment_size(:avatar) + end +end diff --git a/spec/paperclip/media_type_spoof_detector_spec.rb b/spec/paperclip/media_type_spoof_detector_spec.rb new file mode 100644 index 000000000..a631160ff --- /dev/null +++ b/spec/paperclip/media_type_spoof_detector_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' + +describe Paperclip::MediaTypeSpoofDetector do + it 'rejects a file that is named .html and identifies as PNG' do + file = File.open(fixture_file("5k.png")) + assert Paperclip::MediaTypeSpoofDetector.using(file, "5k.html", "image/png").spoofed? + end + + it 'does not reject a file that is named .jpg and identifies as PNG' do + file = File.open(fixture_file("5k.png")) + assert ! Paperclip::MediaTypeSpoofDetector.using(file, "5k.jpg", "image/png").spoofed? + end + + it 'does not reject a file that is named .html and identifies as HTML' do + file = File.open(fixture_file("empty.html")) + assert ! Paperclip::MediaTypeSpoofDetector.using(file, "empty.html", "text/html").spoofed? + end + + it 'does not reject a file that does not have a name' do + file = File.open(fixture_file("empty.html")) + assert ! Paperclip::MediaTypeSpoofDetector.using(file, "", "text/html").spoofed? + end + + it 'does not reject a file that does have an extension' do + file = File.open(fixture_file("empty.html")) + assert ! Paperclip::MediaTypeSpoofDetector.using(file, "data", "text/html").spoofed? + end + + it 'does not reject when the supplied file is an IOAdapter' do + adapter = Paperclip.io_adapters.for(File.new(fixture_file("5k.png"))) + assert ! Paperclip::MediaTypeSpoofDetector.using(adapter, adapter.original_filename, adapter.content_type).spoofed? + end + + it 'does not reject when the extension => content_type is in :content_type_mappings' do + begin + Paperclip.options[:content_type_mappings] = { pem: "text/plain" } + file = Tempfile.open(["test", ".PEM"]) + file.puts "Certificate!" + file.close + adapter = Paperclip.io_adapters.for(File.new(file.path)); + assert ! Paperclip::MediaTypeSpoofDetector.using(adapter, adapter.original_filename, adapter.content_type).spoofed? + ensure + Paperclip.options[:content_type_mappings] = {} + end + end + + context "file named .html and is as HTML, but we're told JPG" do + let(:file) { File.open(fixture_file("empty.html")) } + let(:spoofed?) { Paperclip::MediaTypeSpoofDetector.using(file, "empty.html", "image/jpg").spoofed? } + + it "rejects the file" do + assert spoofed? + end + + it "logs info about the detected spoof" do + Paperclip.expects(:log).with('Content Type Spoof: Filename empty.html (image/jpg from Headers, ["text/html"] from Extension), content type discovered from file command: text/html. See documentation to allow this combination.') + spoofed? + end + end + + context "GIF file named without extension, but we're told GIF" do + let(:file) { File.open(fixture_file("animated")) } + let(:spoofed?) do + Paperclip::MediaTypeSpoofDetector. + using(file, "animated", "image/gif"). + spoofed? + end + + it "accepts the file" do + assert !spoofed? + end + end + + context "GIF file named without extension, but we're told HTML" do + let(:file) { File.open(fixture_file("animated")) } + let(:spoofed?) do + Paperclip::MediaTypeSpoofDetector. + using(file, "animated", "text/html"). + spoofed? + end + + it "rejects the file" do + assert spoofed? + end + end + + it "does not reject if content_type is empty but otherwise checks out" do + file = File.open(fixture_file("empty.html")) + assert ! Paperclip::MediaTypeSpoofDetector.using(file, "empty.html", "").spoofed? + end + + it 'does allow array as :content_type_mappings' do + begin + Paperclip.options[:content_type_mappings] = { + html: ['binary', 'text/html'] + } + file = File.open(fixture_file('empty.html')) + spoofed = Paperclip::MediaTypeSpoofDetector + .using(file, "empty.html", "text/html").spoofed? + assert !spoofed + ensure + Paperclip.options[:content_type_mappings] = {} + end + end + + context "#type_from_file_command" do + let(:file) { File.new(fixture_file("empty.html")) } + let(:detector) { Paperclip::MediaTypeSpoofDetector.new(file, "html", "") } + + it "does work with the output of old versions of file" do + Paperclip.stubs(:run).returns("text/html charset=us-ascii") + expect(detector.send(:type_from_file_command)).to eq("text/html") + end + + it "does work with the output of new versions of file" do + Paperclip.stubs(:run).returns("text/html; charset=us-ascii") + expect(detector.send(:type_from_file_command)).to eq("text/html") + end + end +end diff --git a/spec/paperclip/meta_class_spec.rb b/spec/paperclip/meta_class_spec.rb new file mode 100644 index 000000000..865c01f1d --- /dev/null +++ b/spec/paperclip/meta_class_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe 'Metaclasses' do + context "A meta-class of dummy" do + if active_support_version >= "4.1" || ruby_version < "2.1" + before do + rebuild_model + reset_class("Dummy") + end + + it "is able to use Paperclip like a normal class" do + @dummy = Dummy.new + + assert_nothing_raised do + rebuild_meta_class_of(@dummy) + end + end + + it "works like any other instance" do + @dummy = Dummy.new + rebuild_meta_class_of(@dummy) + + assert_nothing_raised do + @dummy.avatar = File.new(fixture_file("5k.png"), 'rb') + end + assert @dummy.save + end + end + end +end diff --git a/spec/paperclip/paperclip_missing_attachment_styles_spec.rb b/spec/paperclip/paperclip_missing_attachment_styles_spec.rb new file mode 100644 index 000000000..4949fdaeb --- /dev/null +++ b/spec/paperclip/paperclip_missing_attachment_styles_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +describe 'Missing Attachment Styles' do + before do + Paperclip::AttachmentRegistry.clear + end + + after do + File.unlink(Paperclip.registered_attachments_styles_path) rescue nil + end + + it "enables to get and set path to registered styles file" do + assert_equal ROOT.join('tmp/public/system/paperclip_attachments.yml').to_s, Paperclip.registered_attachments_styles_path + Paperclip.registered_attachments_styles_path = '/tmp/config/paperclip_attachments.yml' + assert_equal '/tmp/config/paperclip_attachments.yml', Paperclip.registered_attachments_styles_path + Paperclip.registered_attachments_styles_path = nil + assert_equal ROOT.join('tmp/public/system/paperclip_attachments.yml').to_s, Paperclip.registered_attachments_styles_path + end + + it "is able to get current attachment styles" do + assert_equal Hash.new, Paperclip.send(:current_attachments_styles) + rebuild_model styles: {croppable: '600x600>', big: '1000x1000>'} + expected_hash = { Dummy: {avatar: [:big, :croppable]}} + assert_equal expected_hash, Paperclip.send(:current_attachments_styles) + end + + it "is able to save current attachment styles for further comparison" do + rebuild_model styles: {croppable: '600x600>', big: '1000x1000>'} + Paperclip.save_current_attachments_styles! + expected_hash = { Dummy: {avatar: [:big, :croppable]}} + assert_equal expected_hash, YAML.load_file(Paperclip.registered_attachments_styles_path) + end + + it "is able to read registered attachment styles from file" do + rebuild_model styles: {croppable: '600x600>', big: '1000x1000>'} + Paperclip.save_current_attachments_styles! + expected_hash = { Dummy: {avatar: [:big, :croppable]}} + assert_equal expected_hash, Paperclip.send(:get_registered_attachments_styles) + end + + it "is able to calculate differences between registered styles and current styles" do + rebuild_model styles: {croppable: '600x600>', big: '1000x1000>'} + Paperclip.save_current_attachments_styles! + rebuild_model styles: {thumb: 'x100', export: 'x400>', croppable: '600x600>', big: '1000x1000>'} + expected_hash = { Dummy: {avatar: [:export, :thumb]} } + assert_equal expected_hash, Paperclip.missing_attachments_styles + + ActiveRecord::Base.connection.create_table :books, force: true + class ::Book < ActiveRecord::Base + has_attached_file :cover, styles: {small: 'x100', large: '1000x1000>'} + has_attached_file :sample, styles: {thumb: 'x100'} + end + + expected_hash = { + Dummy: {avatar: [:export, :thumb]}, + Book: {sample: [:thumb], cover: [:large, :small]} + } + assert_equal expected_hash, Paperclip.missing_attachments_styles + Paperclip.save_current_attachments_styles! + assert_equal Hash.new, Paperclip.missing_attachments_styles + end + + it "is able to calculate differences when a new attachment is added to a model" do + rebuild_model styles: {croppable: '600x600>', big: '1000x1000>'} + Paperclip.save_current_attachments_styles! + + class ::Dummy + has_attached_file :photo, styles: {small: 'x100', large: '1000x1000>'} + end + + expected_hash = { + Dummy: {photo: [:large, :small]} + } + assert_equal expected_hash, Paperclip.missing_attachments_styles + Paperclip.save_current_attachments_styles! + assert_equal Hash.new, Paperclip.missing_attachments_styles + end + + # It's impossible to build styles hash without loading from database whole bunch of records + it "skips lambda-styles" do + rebuild_model styles: lambda{ |attachment| attachment.instance.other == 'a' ? {thumb: "50x50#"} : {large: "400x400"} } + assert_equal Hash.new, Paperclip.send(:current_attachments_styles) + end +end diff --git a/spec/paperclip/paperclip_spec.rb b/spec/paperclip/paperclip_spec.rb new file mode 100644 index 000000000..b2b999815 --- /dev/null +++ b/spec/paperclip/paperclip_spec.rb @@ -0,0 +1,192 @@ +require 'spec_helper' + +describe Paperclip do + context ".run" do + before do + Paperclip.options[:log_command] = false + Terrapin::CommandLine.expects(:new).with("convert", "stuff", {}).returns(stub(:run)) + @original_command_line_path = Terrapin::CommandLine.path + end + + after do + Paperclip.options[:log_command] = true + Terrapin::CommandLine.path = @original_command_line_path + end + + it "runs the command with Terrapin" do + Paperclip.run("convert", "stuff") + end + + it "saves Terrapin::CommandLine.path that set before" do + Terrapin::CommandLine.path = "/opt/my_app/bin" + Paperclip.run("convert", "stuff") + expect(Terrapin::CommandLine.path).to match("/opt/my_app/bin") + end + + it "does not duplicate Terrapin::CommandLine.path on multiple runs" do + Terrapin::CommandLine.expects(:new).with("convert", "more_stuff", {}).returns(stub(:run)) + Terrapin::CommandLine.path = nil + Paperclip.options[:command_path] = "/opt/my_app/bin" + Paperclip.run("convert", "stuff") + Paperclip.run("convert", "more_stuff") + + cmd_path = Paperclip.options[:command_path] + assert_equal 1, Terrapin::CommandLine.path.scan(cmd_path).count + end + end + + it 'does not raise errors when doing a lot of running' do + Paperclip.options[:command_path] = ["/usr/local/bin"] * 1024 + Terrapin::CommandLine.path = "/something/else" + 100.times do |x| + Paperclip.run("echo", x.to_s) + end + end + + context "Calling Paperclip.log without options[:logger] set" do + before do + Paperclip.logger = nil + Paperclip.options[:logger] = nil + end + + after do + Paperclip.options[:logger] = ActiveRecord::Base.logger + Paperclip.logger = ActiveRecord::Base.logger + end + + it "does not raise an error when log is called" do + silence_stream(STDOUT) do + Paperclip.log('something') + end + end + end + context "Calling Paperclip.run with a logger" do + it "passes the defined logger if :log_command is set" do + Paperclip.options[:log_command] = true + Terrapin::CommandLine.expects(:new).with("convert", "stuff", logger: Paperclip.logger).returns(stub(:run)) + Paperclip.run("convert", "stuff") + end + end + + context "Paperclip.each_instance_with_attachment" do + before do + @file = File.new(fixture_file("5k.png"), 'rb') + d1 = Dummy.create(avatar: @file) + d2 = Dummy.create + d3 = Dummy.create(avatar: @file) + @expected = [d1, d3] + end + + after { @file.close } + + it "yields every instance of a model that has an attachment" do + actual = [] + Paperclip.each_instance_with_attachment("Dummy", "avatar") do |instance| + actual << instance + end + expect(actual).to match_array @expected + end + end + + it "raises when sent #processor and the name of a class that doesn't exist" do + assert_raises(LoadError){ Paperclip.processor(:boogey_man) } + end + + it "returns a class when sent #processor and the name of a class under Paperclip" do + assert_equal ::Paperclip::Thumbnail, Paperclip.processor(:thumbnail) + end + + it "gets a class from a namespaced class name" do + class ::One; class Two; end; end + assert_equal ::One::Two, Paperclip.class_for("One::Two") + end + + it "raises when class doesn't exist in specified namespace" do + class ::Three; end + class ::Four; end + assert_raises NameError do + Paperclip.class_for("Three::Four") + end + end + + context "An ActiveRecord model with an 'avatar' attachment" do + before do + rebuild_model path: "tmp/:class/omg/:style.:extension" + @file = File.new(fixture_file("5k.png"), 'rb') + end + + after { @file.close } + + it "does not error when trying to also create a 'blah' attachment" do + assert_nothing_raised do + Dummy.class_eval do + has_attached_file :blah + end + end + end + + context "with a subclass" do + before do + class ::SubDummy < Dummy; end + end + + it "is able to use the attachment from the subclass" do + assert_nothing_raised do + @subdummy = SubDummy.create(avatar: @file) + end + end + + after do + SubDummy.delete_all + Object.send(:remove_const, "SubDummy") rescue nil + end + end + + it "has an avatar getter method" do + assert Dummy.new.respond_to?(:avatar) + end + + it "has an avatar setter method" do + assert Dummy.new.respond_to?(:avatar=) + end + + context "that is valid" do + before do + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "is valid" do + assert @dummy.valid? + end + end + + it "does not have Attachment in the ActiveRecord::Base namespace" do + assert_raises(NameError) do + ActiveRecord::Base::Attachment + end + end + end + + context "configuring a custom processor" do + before do + @freedom_processor = Class.new do + def make(file, options = {}, attachment = nil) + file + end + end.new + + Paperclip.configure do |config| + config.register_processor(:freedom, @freedom_processor) + end + end + + it "is able to find the custom processor" do + assert_equal @freedom_processor, Paperclip.processor(:freedom) + end + + after do + Paperclip.clear_processors! + end + end +end diff --git a/spec/paperclip/plural_cache_spec.rb b/spec/paperclip/plural_cache_spec.rb new file mode 100644 index 000000000..32fdb7c11 --- /dev/null +++ b/spec/paperclip/plural_cache_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe 'Plural cache' do + it 'caches pluralizations' do + cache = Paperclip::Interpolations::PluralCache.new + symbol = :box + + first = cache.pluralize_symbol(symbol) + second = cache.pluralize_symbol(symbol) + expect(first).to equal(second) + end + + it 'caches pluralizations and underscores' do + class BigBox ; end + cache = Paperclip::Interpolations::PluralCache.new + klass = BigBox + + first = cache.underscore_and_pluralize_class(klass) + second = cache.underscore_and_pluralize_class(klass) + expect(first).to equal(second) + end + + it 'pluralizes words' do + cache = Paperclip::Interpolations::PluralCache.new + symbol = :box + + expect(cache.pluralize_symbol(symbol)).to eq("boxes") + end + + it 'pluralizes and underscore class names' do + class BigBox ; end + cache = Paperclip::Interpolations::PluralCache.new + klass = BigBox + + expect(cache.underscore_and_pluralize_class(klass)).to eq("big_boxes") + end +end diff --git a/spec/paperclip/processor_helpers_spec.rb b/spec/paperclip/processor_helpers_spec.rb new file mode 100644 index 000000000..73c65a8f3 --- /dev/null +++ b/spec/paperclip/processor_helpers_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Paperclip::ProcessorHelpers do + describe '.load_processor' do + context 'when the file exists in lib/paperclip' do + it 'loads it correctly' do + pathname = Pathname.new('my_app') + main_path = 'main_path' + alternate_path = 'alternate_path' + + Rails.stubs(:root).returns(pathname) + File.expects(:expand_path).with(pathname.join('lib/paperclip', 'custom.rb')).returns(main_path) + File.expects(:expand_path).with(pathname.join('lib/paperclip_processors', 'custom.rb')).returns(alternate_path) + File.expects(:exist?).with(main_path).returns(true) + File.expects(:exist?).with(alternate_path).returns(false) + + Paperclip.expects(:require).with(main_path) + + Paperclip.load_processor(:custom) + end + end + + context 'when the file exists in lib/paperclip_processors' do + it 'loads it correctly' do + pathname = Pathname.new('my_app') + main_path = 'main_path' + alternate_path = 'alternate_path' + + Rails.stubs(:root).returns(pathname) + File.expects(:expand_path).with(pathname.join('lib/paperclip', 'custom.rb')).returns(main_path) + File.expects(:expand_path).with(pathname.join('lib/paperclip_processors', 'custom.rb')).returns(alternate_path) + File.expects(:exist?).with(main_path).returns(false) + File.expects(:exist?).with(alternate_path).returns(true) + + Paperclip.expects(:require).with(alternate_path) + + Paperclip.load_processor(:custom) + end + end + + context 'when the file does not exist in lib/paperclip_processors' do + it 'raises an error' do + pathname = Pathname.new('my_app') + main_path = 'main_path' + alternate_path = 'alternate_path' + + Rails.stubs(:root).returns(pathname) + File.stubs(:expand_path).with(pathname.join('lib/paperclip', 'custom.rb')).returns(main_path) + File.stubs(:expand_path).with(pathname.join('lib/paperclip_processors', 'custom.rb')).returns(alternate_path) + File.stubs(:exist?).with(main_path).returns(false) + File.stubs(:exist?).with(alternate_path).returns(false) + + assert_raises(LoadError) { Paperclip.processor(:custom) } + end + end + end +end diff --git a/spec/paperclip/processor_spec.rb b/spec/paperclip/processor_spec.rb new file mode 100644 index 000000000..e71d1d5fc --- /dev/null +++ b/spec/paperclip/processor_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Paperclip::Processor do + it "instantiates and call #make when sent #make to the class" do + processor = mock + processor.expects(:make).with() + Paperclip::Processor.expects(:new).with(:one, :two, :three).returns(processor) + Paperclip::Processor.make(:one, :two, :three) + end + + context "Calling #convert" do + it "runs the convert command with Terrapin" do + Paperclip.options[:log_command] = false + Terrapin::CommandLine.expects(:new).with("convert", "stuff", {}).returns(stub(:run)) + Paperclip::Processor.new('filename').convert("stuff") + end + end + + context "Calling #identify" do + it "runs the identify command with Terrapin" do + Paperclip.options[:log_command] = false + Terrapin::CommandLine.expects(:new).with("identify", "stuff", {}).returns(stub(:run)) + Paperclip::Processor.new('filename').identify("stuff") + end + end +end diff --git a/spec/paperclip/rails_environment_spec.rb b/spec/paperclip/rails_environment_spec.rb new file mode 100644 index 000000000..56ecdb5a4 --- /dev/null +++ b/spec/paperclip/rails_environment_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Paperclip::RailsEnvironment do + + it "returns nil when Rails isn't defined" do + resetting_rails_to(nil) do + expect(Paperclip::RailsEnvironment.get).to be_nil + end + end + + it "returns nil when Rails.env isn't defined" do + resetting_rails_to({}) do + expect(Paperclip::RailsEnvironment.get).to be_nil + end + end + + it "returns the value of Rails.env if it is set" do + resetting_rails_to(OpenStruct.new(env: "foo")) do + expect(Paperclip::RailsEnvironment.get).to eq "foo" + end + end + + def resetting_rails_to(new_value) + begin + previous_rails = Object.send(:remove_const, "Rails") + Object.const_set("Rails", new_value) unless new_value.nil? + yield + ensure + Object.send(:remove_const, "Rails") if Object.const_defined?("Rails") + Object.const_set("Rails", previous_rails) + end + end +end diff --git a/spec/paperclip/rake_spec.rb b/spec/paperclip/rake_spec.rb new file mode 100644 index 000000000..802981644 --- /dev/null +++ b/spec/paperclip/rake_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' +require 'rake' +load './lib/tasks/paperclip.rake' + +describe Rake do + context "calling `rake paperclip:refresh:thumbnails`" do + before do + rebuild_model + Paperclip::Task.stubs(:obtain_class).returns('Dummy') + @bogus_instance = Dummy.new + @bogus_instance.id = 'some_id' + @bogus_instance.avatar.stubs(:reprocess!) + @valid_instance = Dummy.new + @valid_instance.avatar.stubs(:reprocess!) + Paperclip::Task.stubs(:log_error) + Paperclip.stubs(:each_instance_with_attachment).multiple_yields @bogus_instance, @valid_instance + end + context "when there is an exception in reprocess!" do + before do + @bogus_instance.avatar.stubs(:reprocess!).raises + end + + it "catches the exception" do + assert_nothing_raised do + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + end + + it "continues to the next instance" do + @valid_instance.avatar.expects(:reprocess!) + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + + it "prints the exception" do + exception_msg = 'Some Exception' + @bogus_instance.avatar.stubs(:reprocess!).raises(exception_msg) + Paperclip::Task.expects(:log_error).with do |str| + str.match exception_msg + end + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + + it "prints the class name" do + Paperclip::Task.expects(:log_error).with do |str| + str.match 'Dummy' + end + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + + it "prints the instance ID" do + Paperclip::Task.expects(:log_error).with do |str| + str.match "ID #{@bogus_instance.id}" + end + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + end + + context "when there is an error in reprocess!" do + before do + @errors = mock('errors') + @errors.stubs(:full_messages).returns(['']) + @errors.stubs(:blank?).returns(false) + @bogus_instance.stubs(:errors).returns(@errors) + end + + it "continues to the next instance" do + @valid_instance.avatar.expects(:reprocess!) + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + + it "prints the error" do + error_msg = 'Some Error' + @errors.stubs(:full_messages).returns([error_msg]) + Paperclip::Task.expects(:log_error).with do |str| + str.match error_msg + end + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + + it "prints the class name" do + Paperclip::Task.expects(:log_error).with do |str| + str.match 'Dummy' + end + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + + it "prints the instance ID" do + Paperclip::Task.expects(:log_error).with do |str| + str.match "ID #{@bogus_instance.id}" + end + ::Rake::Task['paperclip:refresh:thumbnails'].execute + end + end + end + + context "Paperclip::Task.log_error method" do + it "prints its argument to STDERR" do + msg = 'Some Message' + $stderr.expects(:puts).with(msg) + Paperclip::Task.log_error(msg) + end + end +end diff --git a/spec/paperclip/schema_spec.rb b/spec/paperclip/schema_spec.rb new file mode 100644 index 000000000..b21c95b8f --- /dev/null +++ b/spec/paperclip/schema_spec.rb @@ -0,0 +1,248 @@ +require 'spec_helper' +require 'paperclip/schema' +require 'active_support/testing/deprecation' + +describe Paperclip::Schema do + include ActiveSupport::Testing::Deprecation + + before do + rebuild_class + end + + after do + Dummy.connection.drop_table :dummies rescue nil + end + + context "within table definition" do + context "using #has_attached_file" do + before do + ActiveSupport::Deprecation.silenced = false + end + it "creates attachment columns" do + Dummy.connection.create_table :dummies, force: true do |t| + ActiveSupport::Deprecation.silence do + t.has_attached_file :avatar + end + end + + columns = Dummy.columns.map{ |column| [column.name, column.type] } + + expect(columns).to include(['avatar_file_name', :string]) + expect(columns).to include(['avatar_content_type', :string]) + expect(columns).to include(['avatar_file_size', :integer]) + expect(columns).to include(['avatar_updated_at', :datetime]) + end + + it "displays deprecation warning" do + Dummy.connection.create_table :dummies, force: true do |t| + assert_deprecated do + t.has_attached_file :avatar + end + end + end + end + + context "using #attachment" do + before do + Dummy.connection.create_table :dummies, force: true do |t| + t.attachment :avatar + end + end + + it "creates attachment columns" do + columns = Dummy.columns.map{ |column| [column.name, column.type] } + + expect(columns).to include(['avatar_file_name', :string]) + expect(columns).to include(['avatar_content_type', :string]) + expect(columns).to include(['avatar_file_size', :integer]) + expect(columns).to include(['avatar_updated_at', :datetime]) + end + end + + context "using #attachment with options" do + before do + Dummy.connection.create_table :dummies, force: true do |t| + t.attachment :avatar, default: 1, file_name: { default: 'default' } + end + end + + it "sets defaults on columns" do + defaults_columns = ["avatar_file_name", "avatar_content_type", "avatar_file_size"] + columns = Dummy.columns.select { |e| defaults_columns.include? e.name } + + expect(columns).to have_column("avatar_file_name").with_default("default") + expect(columns).to have_column("avatar_content_type").with_default("1") + expect(columns).to have_column("avatar_file_size").with_default(1) + end + end + end + + context "within schema statement" do + before do + Dummy.connection.create_table :dummies, force: true + end + + context "migrating up" do + context "with single attachment" do + before do + Dummy.connection.add_attachment :dummies, :avatar + end + + it "creates attachment columns" do + columns = Dummy.columns.map{ |column| [column.name, column.type] } + + expect(columns).to include(['avatar_file_name', :string]) + expect(columns).to include(['avatar_content_type', :string]) + expect(columns).to include(['avatar_file_size', :integer]) + expect(columns).to include(['avatar_updated_at', :datetime]) + end + end + + context "with single attachment and options" do + before do + Dummy.connection.add_attachment :dummies, :avatar, default: '1', file_name: { default: 'default' } + end + + it "sets defaults on columns" do + defaults_columns = ["avatar_file_name", "avatar_content_type", "avatar_file_size"] + columns = Dummy.columns.select { |e| defaults_columns.include? e.name } + + expect(columns).to have_column("avatar_file_name").with_default("default") + expect(columns).to have_column("avatar_content_type").with_default("1") + expect(columns).to have_column("avatar_file_size").with_default(1) + end + end + + context "with multiple attachments" do + before do + Dummy.connection.add_attachment :dummies, :avatar, :photo + end + + it "creates attachment columns" do + columns = Dummy.columns.map{ |column| [column.name, column.type] } + + expect(columns).to include(['avatar_file_name', :string]) + expect(columns).to include(['avatar_content_type', :string]) + expect(columns).to include(['avatar_file_size', :integer]) + expect(columns).to include(['avatar_updated_at', :datetime]) + expect(columns).to include(['photo_file_name', :string]) + expect(columns).to include(['photo_content_type', :string]) + expect(columns).to include(['photo_file_size', :integer]) + expect(columns).to include(['photo_updated_at', :datetime]) + end + end + + context "with multiple attachments and options" do + before do + Dummy.connection.add_attachment :dummies, :avatar, :photo, default: '1', file_name: { default: 'default' } + end + + it "sets defaults on columns" do + defaults_columns = ["avatar_file_name", "avatar_content_type", "avatar_file_size", "photo_file_name", "photo_content_type", "photo_file_size"] + columns = Dummy.columns.select { |e| defaults_columns.include? e.name } + + expect(columns).to have_column("avatar_file_name").with_default("default") + expect(columns).to have_column("avatar_content_type").with_default("1") + expect(columns).to have_column("avatar_file_size").with_default(1) + expect(columns).to have_column("photo_file_name").with_default("default") + expect(columns).to have_column("photo_content_type").with_default("1") + expect(columns).to have_column("photo_file_size").with_default(1) + end + end + + context "with no attachment" do + it "raises an error" do + assert_raises ArgumentError do + Dummy.connection.add_attachment :dummies + end + end + end + end + + context "migrating down" do + before do + Dummy.connection.change_table :dummies do |t| + t.column :avatar_file_name, :string + t.column :avatar_content_type, :string + t.column :avatar_file_size, :integer + t.column :avatar_updated_at, :datetime + end + end + + context "using #drop_attached_file" do + before do + ActiveSupport::Deprecation.silenced = false + end + it "removes the attachment columns" do + ActiveSupport::Deprecation.silence do + Dummy.connection.drop_attached_file :dummies, :avatar + end + + columns = Dummy.columns.map{ |column| [column.name, column.type] } + + expect(columns).to_not include(['avatar_file_name', :string]) + expect(columns).to_not include(['avatar_content_type', :string]) + expect(columns).to_not include(['avatar_file_size', :integer]) + expect(columns).to_not include(['avatar_updated_at', :datetime]) + end + + it "displays a deprecation warning" do + assert_deprecated do + Dummy.connection.drop_attached_file :dummies, :avatar + end + end + end + + context "using #remove_attachment" do + context "with single attachment" do + before do + Dummy.connection.remove_attachment :dummies, :avatar + end + + it "removes the attachment columns" do + columns = Dummy.columns.map{ |column| [column.name, column.type] } + + expect(columns).to_not include(['avatar_file_name', :string]) + expect(columns).to_not include(['avatar_content_type', :string]) + expect(columns).to_not include(['avatar_file_size', :integer]) + expect(columns).to_not include(['avatar_updated_at', :datetime]) + end + end + + context "with multiple attachments" do + before do + Dummy.connection.change_table :dummies do |t| + t.column :photo_file_name, :string + t.column :photo_content_type, :string + t.column :photo_file_size, :integer + t.column :photo_updated_at, :datetime + end + + Dummy.connection.remove_attachment :dummies, :avatar, :photo + end + + it "removes the attachment columns" do + columns = Dummy.columns.map{ |column| [column.name, column.type] } + + expect(columns).to_not include(['avatar_file_name', :string]) + expect(columns).to_not include(['avatar_content_type', :string]) + expect(columns).to_not include(['avatar_file_size', :integer]) + expect(columns).to_not include(['avatar_updated_at', :datetime]) + expect(columns).to_not include(['photo_file_name', :string]) + expect(columns).to_not include(['photo_content_type', :string]) + expect(columns).to_not include(['photo_file_size', :integer]) + expect(columns).to_not include(['photo_updated_at', :datetime]) + end + end + + context "with no attachment" do + it "raises an error" do + assert_raises ArgumentError do + Dummy.connection.remove_attachment :dummies + end + end + end + end + end + end +end diff --git a/spec/paperclip/storage/filesystem_spec.rb b/spec/paperclip/storage/filesystem_spec.rb new file mode 100644 index 000000000..ea2569478 --- /dev/null +++ b/spec/paperclip/storage/filesystem_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe Paperclip::Storage::Filesystem do + context "Filesystem" do + context "normal file" do + before do + rebuild_model styles: { thumbnail: "25x25#" } + @dummy = Dummy.create! + + @file = File.open(fixture_file('5k.png')) + @dummy.avatar = @file + end + + after { @file.close } + + it "allows file assignment" do + assert @dummy.save + end + + it "stores the original" do + @dummy.save + assert_file_exists(@dummy.avatar.path) + end + + it "stores the thumbnail" do + @dummy.save + assert_file_exists(@dummy.avatar.path(:thumbnail)) + end + + it "is rewinded after flush_writes" do + @dummy.avatar.instance_eval "def after_flush_writes; end" + + files = @dummy.avatar.queued_for_write.values + @dummy.save + assert files.none?(&:eof?), "Expect all the files to be rewinded." + end + + it "is removed after after_flush_writes" do + paths = @dummy.avatar.queued_for_write.values.map(&:path) + @dummy.save + assert paths.none?{ |path| File.exist?(path) }, + "Expect all the files to be deleted." + end + + it 'copies the file to a known location with copy_to_local_file' do + tempfile = Tempfile.new("known_location") + @dummy.avatar.copy_to_local_file(:original, tempfile.path) + tempfile.rewind + assert_equal @file.read, tempfile.read + tempfile.close + end + end + + context "with file that has space in file name" do + before do + rebuild_model styles: { thumbnail: "25x25#" } + @dummy = Dummy.create! + + @file = File.open(fixture_file('spaced file.png')) + @dummy.avatar = @file + @dummy.save + end + + after { @file.close } + + it "stores the file" do + assert_file_exists(@dummy.avatar.path) + end + + it "returns a replaced version for path" do + assert_match /.+\/spaced_file\.png/, @dummy.avatar.path + end + + it "returns a replaced version for url" do + assert_match /.+\/spaced_file\.png/, @dummy.avatar.url + end + end + end +end diff --git a/spec/paperclip/storage/fog_spec.rb b/spec/paperclip/storage/fog_spec.rb new file mode 100644 index 000000000..89479f3a5 --- /dev/null +++ b/spec/paperclip/storage/fog_spec.rb @@ -0,0 +1,566 @@ +require 'spec_helper' +require 'fog/aws' +require 'fog/local' +require 'timecop' + +describe Paperclip::Storage::Fog do + context "" do + before { Fog.mock! } + + context "with credentials provided in a path string" do + before do + rebuild_model styles: { medium: "300x300>", thumb: "100x100>" }, + storage: :fog, + url: '/:attachment/:filename', + fog_directory: "paperclip", + fog_credentials: fixture_file('fog.yml') + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + it "has the proper information loading credentials from a file" do + assert_equal @dummy.avatar.fog_credentials[:provider], 'AWS' + end + end + + context "with credentials provided in a File object" do + before do + rebuild_model styles: { medium: "300x300>", thumb: "100x100>" }, + storage: :fog, + url: '/:attachment/:filename', + fog_directory: "paperclip", + fog_credentials: File.open(fixture_file('fog.yml')) + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + it "has the proper information loading credentials from a file" do + assert_equal @dummy.avatar.fog_credentials[:provider], 'AWS' + end + end + + context "with default values for path and url" do + before do + rebuild_model styles: { medium: "300x300>", thumb: "100x100>" }, + storage: :fog, + url: '/:attachment/:filename', + fog_directory: "paperclip", + fog_credentials: { + provider: 'AWS', + aws_access_key_id: 'AWS_ID', + aws_secret_access_key: 'AWS_SECRET' + } + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + it "is able to interpolate the path without blowing up" do + assert_equal File.expand_path(File.join(File.dirname(__FILE__), "../../../tmp/public/avatars/5k.png")), + @dummy.avatar.path + end + end + + context "with no path or url given and using defaults" do + before do + rebuild_model styles: { medium: "300x300>", thumb: "100x100>" }, + storage: :fog, + fog_directory: "paperclip", + fog_credentials: { + provider: 'AWS', + aws_access_key_id: 'AWS_ID', + aws_secret_access_key: 'AWS_SECRET' + } + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.id = 1 + @dummy.avatar = @file + end + + after { @file.close } + + it "has correct path and url from interpolated defaults" do + assert_equal "dummies/avatars/000/000/001/original/5k.png", @dummy.avatar.path + end + end + + context "with file params provided as lambda" do + before do + fog_file = lambda{ |a| { custom_header: a.instance.custom_method }} + klass = rebuild_model storage: :fog, + fog_file: fog_file + + klass.class_eval do + def custom_method + 'foobar' + end + end + + + @dummy = Dummy.new + end + + it "is able to evaluate correct values for file headers" do + assert_equal @dummy.avatar.send(:fog_file), { custom_header: 'foobar' } + end + end + + before do + @fog_directory = 'papercliptests' + + @credentials = { + provider: 'AWS', + aws_access_key_id: 'ID', + aws_secret_access_key: 'SECRET' + } + + @connection = Fog::Storage.new(@credentials) + @connection.directories.create( + key: @fog_directory + ) + + @options = { + fog_directory: @fog_directory, + fog_credentials: @credentials, + fog_host: nil, + fog_file: {cache_control: 1234}, + path: ":attachment/:basename:dotextension", + storage: :fog + } + + rebuild_model(@options) + end + + it "is extended by the Fog module" do + assert Dummy.new.avatar.is_a?(Paperclip::Storage::Fog) + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after do + @file.close + directory = @connection.directories.new(key: @fog_directory) + directory.files.each {|file| file.destroy} + directory.destroy + end + + it "is rewound after flush_writes" do + @dummy.avatar.instance_eval "def after_flush_writes; end" + + files = @dummy.avatar.queued_for_write.values + @dummy.save + assert files.none?(&:eof?), "Expect all the files to be rewinded." + end + + it "is removed after after_flush_writes" do + paths = @dummy.avatar.queued_for_write.values.map(&:path) + @dummy.save + assert paths.none?{ |path| File.exist?(path) }, + "Expect all the files to be deleted." + end + + it 'is able to be copied to a local file' do + @dummy.save + tempfile = Tempfile.new("known_location") + tempfile.binmode + @dummy.avatar.copy_to_local_file(:original, tempfile.path) + tempfile.rewind + assert_equal @connection.directories.get(@fog_directory).files.get(@dummy.avatar.path).body, + tempfile.read + tempfile.close + end + + it 'is able to be handled when missing while copying to a local file' do + tempfile = Tempfile.new("known_location") + tempfile.binmode + assert_equal false, @dummy.avatar.copy_to_local_file(:original, tempfile.path) + tempfile.close + end + + it "passes the content type to the Fog::Storage::AWS::Files instance" do + Fog::Storage::AWS::Files.any_instance.expects(:create).with do |hash| + hash[:content_type] + end + @dummy.save + end + + context "without a bucket" do + before do + @connection.directories.get(@fog_directory).destroy + end + + it "creates the bucket" do + assert @dummy.save + assert @connection.directories.get(@fog_directory) + end + + it "sucessfully rewinds the file during bucket creation" do + assert @dummy.save + expect(Paperclip.io_adapters.for(@dummy.avatar).read.length).to be > 0 + end + end + + context "with a bucket" do + it "succeeds" do + assert @dummy.save + end + end + + context "without a fog_host" do + before do + rebuild_model(@options.merge(fog_host: nil)) + @dummy = Dummy.new + @dummy.avatar = StringIO.new('.') + @dummy.save + end + + it "provides a public url" do + assert !@dummy.avatar.url.nil? + end + end + + context "with a fog_host" do + before do + rebuild_model(@options.merge(fog_host: 'http://example.com')) + @dummy = Dummy.new + @dummy.avatar = StringIO.new(".\n") + @dummy.save + end + + it "provides a public url" do + expect(@dummy.avatar.url).to match(/^http:\/\/example\.com\/avatars\/data\?\d*$/) + end + end + + context "with a fog_host that includes a wildcard placeholder" do + before do + rebuild_model( + fog_directory: @fog_directory, + fog_credentials: @credentials, + fog_host: 'http://img%d.example.com', + path: ":attachment/:basename:dotextension", + storage: :fog + ) + @dummy = Dummy.new + @dummy.avatar = StringIO.new(".\n") + @dummy.save + end + + it "provides a public url" do + expect(@dummy.avatar.url).to match(/^http:\/\/img[0123]\.example\.com\/avatars\/data\?\d*$/) + end + end + + context "with fog_public set to false" do + before do + rebuild_model(@options.merge(fog_public: false)) + @dummy = Dummy.new + @dummy.avatar = StringIO.new('.') + @dummy.save + end + + it 'sets the @fog_public instance variable to false' do + assert_equal false, @dummy.avatar.instance_variable_get('@options')[:fog_public] + assert_equal false, @dummy.avatar.fog_public + end + end + + context "with fog_public as a proc" do + let(:proc) { ->(attachment) { !attachment } } + + before do + rebuild_model(@options.merge(fog_public: proc)) + @dummy = Dummy.new + @dummy.avatar = StringIO.new(".") + @dummy.save + end + + it "sets the @fog_public instance variable to false" do + assert_equal proc, @dummy.avatar.instance_variable_get("@options")[:fog_public] + assert_equal false, @dummy.avatar.fog_public + end + end + + context "with styles set and fog_public set to false" do + before do + rebuild_model(@options.merge(fog_public: false, styles: { medium: "300x300>", thumb: "100x100>" })) + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it 'sets the @fog_public for a particular style to false' do + assert_equal false, @dummy.avatar.instance_variable_get('@options')[:fog_public] + assert_equal false, @dummy.avatar.fog_public(:thumb) + end + end + + context "with styles set and fog_public set per-style" do + before do + rebuild_model(@options.merge(fog_public: { medium: false, thumb: true}, styles: { medium: "300x300>", thumb: "100x100>" })) + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it 'sets the fog_public for a particular style to correct value' do + assert_equal false, @dummy.avatar.fog_public(:medium) + assert_equal true, @dummy.avatar.fog_public(:thumb) + end + end + + context "with fog_public not set" do + before do + rebuild_model(@options) + @dummy = Dummy.new + @dummy.avatar = StringIO.new('.') + @dummy.save + end + + it "defaults fog_public to true" do + assert_equal true, @dummy.avatar.fog_public + end + end + + context "with scheme set" do + before do + rebuild_model(@options.merge(:fog_credentials => @credentials.merge(:scheme => 'http'))) + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it "honors the scheme in public url" do + assert_match(/^http:\/\//, @dummy.avatar.url) + end + it "honors the scheme in expiring url" do + assert_match(/^http:\/\//, @dummy.avatar.expiring_url) + end + end + + context "with scheme not set" do + before do + rebuild_model(@options) + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it "provides HTTPS public url" do + assert_match(/^https:\/\//, @dummy.avatar.url) + end + it "provides HTTPS expiring url" do + assert_match(/^https:\/\//, @dummy.avatar.expiring_url) + end + end + + context "with a valid bucket name for a subdomain" do + before { @dummy.stubs(:new_record?).returns(false) } + + it "provides an url in subdomain style" do + assert_match(/^https:\/\/papercliptests.s3.amazonaws.com\/avatars\/5k.png/, @dummy.avatar.url) + end + + it "provides an url that expires in subdomain style" do + assert_match(/^https:\/\/papercliptests.s3.amazonaws.com\/avatars\/5k.png.+Expires=.+$/, @dummy.avatar.expiring_url) + end + end + + context "generating an expiring url" do + it "generates the same url when using Times and Integer offsets" do + Timecop.freeze do + offset = 1234 + rebuild_model(@options) + dummy = Dummy.new + dummy.avatar = StringIO.new('.') + + assert_equal dummy.avatar.expiring_url(offset), + dummy.avatar.expiring_url(Time.now + offset ) + end + end + + it 'matches the default url if there is no assignment' do + dummy = Dummy.new + assert_equal dummy.avatar.url, dummy.avatar.expiring_url + end + + it 'matches the default url when given a style if there is no assignment' do + dummy = Dummy.new + assert_equal dummy.avatar.url(:thumb), dummy.avatar.expiring_url(3600, :thumb) + end + end + + context "with an invalid bucket name for a subdomain" do + before do + rebuild_model(@options.merge(fog_directory: "this_is_invalid")) + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it "does not match the bucket-subdomain restrictions" do + invalid_subdomains = %w(this_is_invalid in iamareallylongbucketnameiamareallylongbucketnameiamareallylongbu invalid- inval..id inval-.id inval.-id -invalid 192.168.10.2) + invalid_subdomains.each do |name| + assert_no_match Paperclip::Storage::Fog::AWS_BUCKET_SUBDOMAIN_RESTRICTON_REGEX, name + end + end + + it "provides an url in folder style" do + assert_match(/^https:\/\/s3.amazonaws.com\/this_is_invalid\/avatars\/5k.png\?\d*$/, @dummy.avatar.url) + end + + it "provides a url that expires in folder style" do + assert_match(/^https:\/\/s3.amazonaws.com\/this_is_invalid\/avatars\/5k.png.+Expires=.+$/, @dummy.avatar.expiring_url) + end + + end + + context "with a proc for a bucket name evaluating a model method" do + before do + @dynamic_fog_directory = 'dynamicpaperclip' + rebuild_model(@options.merge(fog_directory: lambda { |attachment| attachment.instance.bucket_name })) + @dummy = Dummy.new + @dummy.stubs(:bucket_name).returns(@dynamic_fog_directory) + @dummy.avatar = @file + @dummy.save + end + + it "has created the bucket" do + assert @connection.directories.get(@dynamic_fog_directory).inspect + end + + it "provides an url using dynamic bucket name" do + assert_match(/^https:\/\/dynamicpaperclip.s3.amazonaws.com\/avatars\/5k.png\?\d*$/, @dummy.avatar.url) + end + end + + context "with a proc for the fog_host evaluating a model method" do + before do + rebuild_model(@options.merge(fog_host: lambda { |attachment| attachment.instance.fog_host })) + @dummy = Dummy.new + @dummy.stubs(:fog_host).returns('http://dynamicfoghost.com') + @dummy.avatar = @file + @dummy.save + end + + it "provides a public url" do + assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.url) + end + + end + + context "with a custom fog_host" do + before do + rebuild_model(@options.merge(fog_host: "http://dynamicfoghost.com")) + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it "provides a public url" do + assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.url) + end + + it "provides an expiring url" do + assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.expiring_url) + end + + context "with an invalid bucket name for a subdomain" do + before do + rebuild_model(@options.merge({fog_directory: "this_is_invalid", fog_host: "http://dynamicfoghost.com"})) + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it "provides an expiring url" do + assert_match(/http:\/\/dynamicfoghost\.com/, @dummy.avatar.expiring_url) + end + end + + end + + context "with a proc for the fog_credentials evaluating a model method" do + before do + @dynamic_fog_credentials = { + provider: 'AWS', + aws_access_key_id: 'DYNAMIC_ID', + aws_secret_access_key: 'DYNAMIC_SECRET' + } + rebuild_model(@options.merge(fog_credentials: lambda { |attachment| attachment.instance.fog_credentials })) + @dummy = Dummy.new + @dummy.stubs(:fog_credentials).returns(@dynamic_fog_credentials) + @dummy.avatar = @file + @dummy.save + end + + it "provides a public url" do + assert_equal @dummy.avatar.fog_credentials, @dynamic_fog_credentials + end + end + + context "with custom fog_options" do + before do + rebuild_model( + @options.merge(fog_options: { multipart_chunk_size: 104857600 }), + ) + @dummy = Dummy.new + @dummy.avatar = @file + end + + it "applies the options to the fog #create call" do + files = stub + @dummy.avatar.stubs(:directory).returns stub(files: files) + files.expects(:create).with( + has_entries(multipart_chunk_size: 104857600), + ) + @dummy.save + end + end + end + + end + + context "when using local storage" do + before do + Fog.unmock! + rebuild_model styles: { medium: "300x300>", thumb: "100x100>" }, + storage: :fog, + url: '/:attachment/:filename', + fog_directory: "paperclip", + fog_credentials: { provider: :local, local_root: "." }, + fog_host: 'localhost' + + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.stubs(:new_record?).returns(false) + end + + after do + @file.close + Fog.mock! + end + + it "returns the public url in place of the expiring url" do + assert_match @dummy.avatar.public_url, @dummy.avatar.expiring_url + end + end +end diff --git a/spec/paperclip/storage/s3_live_spec.rb b/spec/paperclip/storage/s3_live_spec.rb new file mode 100644 index 000000000..e9896e779 --- /dev/null +++ b/spec/paperclip/storage/s3_live_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +unless ENV["S3_BUCKET"].blank? + describe Paperclip::Storage::S3, 'Live S3' do + context "when assigning an S3 attachment directly to another model" do + before do + rebuild_model styles: { thumb: "100x100", square: "32x32#" }, + storage: :s3, + bucket: ENV["S3_BUCKET"], + path: ":class/:attachment/:id/:style.:extension", + s3_region: ENV["S3_REGION"], + s3_credentials: { + access_key_id: ENV['AWS_ACCESS_KEY_ID'], + secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] + } + + @file = File.new(fixture_file("5k.png")) + end + + it "does not raise any error" do + @attachment = Dummy.new.avatar + @attachment.assign(@file) + @attachment.save + + @attachment2 = Dummy.new.avatar + @attachment2.assign(@file) + @attachment2.save + end + + it "allows assignment from another S3 object" do + @attachment = Dummy.new.avatar + @attachment.assign(@file) + @attachment.save + + @attachment2 = Dummy.new.avatar + @attachment2.assign(@attachment) + @attachment2.save + end + + after { @file.close } + end + + context "Generating an expiring url on a nonexistant attachment" do + before do + rebuild_model styles: { thumb: "100x100", square: "32x32#" }, + storage: :s3, + bucket: ENV["S3_BUCKET"], + path: ":class/:attachment/:id/:style.:extension", + s3_region: ENV["S3_REGION"], + s3_credentials: { + access_key_id: ENV['AWS_ACCESS_KEY_ID'], + secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] + } + + @dummy = Dummy.new + end + + it "returns a missing url" do + expect(@dummy.avatar.expiring_url).to eq @dummy.avatar.url + end + end + + context "Using S3 for real, an attachment with S3 storage" do + before do + rebuild_model styles: { thumb: "100x100", square: "32x32#" }, + storage: :s3, + bucket: ENV["S3_BUCKET"], + path: ":class/:attachment/:id/:style.:extension", + s3_region: ENV["S3_REGION"], + s3_credentials: { + access_key_id: ENV['AWS_ACCESS_KEY_ID'], + secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] + } + + Dummy.delete_all + @dummy = Dummy.new + end + + it "is extended by the S3 module" do + assert Dummy.new.avatar.is_a?(Paperclip::Storage::S3) + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy.avatar = @file + end + + after do + @file.close + @dummy.destroy + end + + context "and saved" do + before do + @dummy.save + end + + it "is on S3" do + assert true + end + end + end + end + + context "An attachment that uses S3 for storage and has spaces in file name" do + before do + rebuild_model styles: { thumb: "100x100", square: "32x32#" }, + storage: :s3, + bucket: ENV["S3_BUCKET"], + s3_region: ENV["S3_REGION"], + url: ":s3_domain_url", + path: "/:class/:attachment/:id_partition/:style/:filename", + s3_credentials: { + access_key_id: ENV['AWS_ACCESS_KEY_ID'], + secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] + } + + Dummy.delete_all + @file = File.new(fixture_file('spaced file.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + it "returns a replaced version for path" do + assert_match /.+\/spaced_file\.png/, @dummy.avatar.path + end + + it "returns a replaced version for url" do + assert_match /.+\/spaced_file\.png/, @dummy.avatar.url + end + + it "is accessible" do + assert_success_response @dummy.avatar.url + end + + it "is reprocessable" do + assert @dummy.avatar.reprocess! + end + + it "is destroyable" do + url = @dummy.avatar.url + @dummy.destroy + assert_forbidden_response url + end + end + + context "An attachment that uses S3 for storage and uses AES256 encryption" do + before do + rebuild_model styles: { thumb: "100x100", square: "32x32#" }, + storage: :s3, + bucket: ENV["S3_BUCKET"], + path: ":class/:attachment/:id/:style.:extension", + s3_region: ENV["S3_REGION"], + s3_credentials: { + access_key_id: ENV['AWS_ACCESS_KEY_ID'], + secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] + }, + s3_server_side_encryption: "AES256" + Dummy.delete_all + @dummy = Dummy.new + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy.avatar = @file + end + + after do + @file.close + @dummy.destroy + end + + context "and saved" do + before do + @dummy.save + end + + it "is encrypted on S3" do + assert @dummy.avatar.s3_object.server_side_encryption == "AES256" + end + end + end + end + end +end diff --git a/spec/paperclip/storage/s3_spec.rb b/spec/paperclip/storage/s3_spec.rb new file mode 100644 index 000000000..f92c0e0af --- /dev/null +++ b/spec/paperclip/storage/s3_spec.rb @@ -0,0 +1,1693 @@ +require "spec_helper" +require "aws-sdk-s3" + +describe Paperclip::Storage::S3 do + before do + Aws.config[:stub_responses] = true + end + + def aws2_add_region + { s3_region: 'us-east-1' } + end + + context "Parsing S3 credentials" do + before do + @proxy_settings = {host: "127.0.0.1", port: 8888, user: "foo", password: "bar"} + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + http_proxy: @proxy_settings, + s3_credentials: {not: :important} + @dummy = Dummy.new + @avatar = @dummy.avatar + end + + it "gets the correct credentials when RAILS_ENV is production" do + rails_env("production") do + assert_equal({key: "12345"}, + @avatar.parse_credentials('production' => {key: '12345'}, + development: {key: "54321"})) + end + end + + it "gets the correct credentials when RAILS_ENV is development" do + rails_env("development") do + assert_equal({key: "54321"}, + @avatar.parse_credentials('production' => {key: '12345'}, + development: {key: "54321"})) + end + end + + it "returns the argument if the key does not exist" do + rails_env("not really an env") do + assert_equal({test: "12345"}, @avatar.parse_credentials(test: "12345")) + end + end + + it "supports HTTP proxy settings" do + rails_env("development") do + assert_equal(true, @avatar.using_http_proxy?) + assert_equal(@proxy_settings[:host], @avatar.http_proxy_host) + assert_equal(@proxy_settings[:port], @avatar.http_proxy_port) + assert_equal(@proxy_settings[:user], @avatar.http_proxy_user) + assert_equal(@proxy_settings[:password], @avatar.http_proxy_password) + end + end + + end + + context ":bucket option via :s3_credentials" do + + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {bucket: 'testing'} + @dummy = Dummy.new + end + + it "populates #bucket_name" do + assert_equal @dummy.avatar.bucket_name, 'testing' + end + + end + + context ":bucket option" do + + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", s3_credentials: {} + @dummy = Dummy.new + end + + it "populates #bucket_name" do + assert_equal @dummy.avatar.bucket_name, 'testing' + end + + end + + context "missing :bucket option" do + + before do + rebuild_model (aws2_add_region).merge storage: :s3, + http_proxy: @proxy_settings, + s3_credentials: {not: :important} + + @dummy = Dummy.new + @dummy.avatar = stringy_file + + end + + it "raises an argument error" do + expect { @dummy.save }.to raise_error(ArgumentError, /missing required :bucket option/) + end + + end + + context "" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + url: ":s3_path_url" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an S3 path" do + assert_match %r{^//s3.amazonaws.com/bucket/avatars/data[^\.]}, @dummy.avatar.url + end + + it "uses the correct bucket" do + assert_equal "bucket", @dummy.avatar.s3_bucket.name + end + + it "uses the correct key" do + assert_equal "avatars/data", @dummy.avatar.s3_object.key + end + end + + context "s3_protocol" do + ["http", :http, ""].each do |protocol| + context "as #{protocol.inspect}" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_protocol: protocol + @dummy = Dummy.new + end + + it "returns the s3_protocol in string" do + assert_equal protocol.to_s, @dummy.avatar.s3_protocol + end + end + end + end + + context "s3_protocol: 'https'" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + s3_protocol: 'https', + bucket: "bucket", + path: ":attachment/:basename:dotextension" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an S3 path" do + assert_match %r{^https://s3.amazonaws.com/bucket/avatars/data[^\.]}, @dummy.avatar.url + end + end + + context "s3_protocol: ''" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + s3_protocol: '', + bucket: "bucket", + path: ":attachment/:basename:dotextension" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a protocol-relative URL" do + assert_match %r{^//s3.amazonaws.com/bucket/avatars/data[^\.]}, @dummy.avatar.url + end + end + + context "s3_protocol: :https" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + s3_protocol: :https, + bucket: "bucket", + path: ":attachment/:basename:dotextension" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an S3 path" do + assert_match %r{^https://s3.amazonaws.com/bucket/avatars/data[^\.]}, @dummy.avatar.url + end + end + + context "s3_protocol: ''" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + s3_protocol: '', + bucket: "bucket", + path: ":attachment/:basename:dotextension" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an S3 path" do + assert_match %r{^//s3.amazonaws.com/bucket/avatars/data[^\.]}, @dummy.avatar.url + end + end + + context "An attachment that uses S3 for storage and has the style in the path" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + styles: { + thumb: "80x80>" + }, + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + } + + @dummy = Dummy.new + @dummy.avatar = stringy_file + @avatar = @dummy.avatar + end + + it "uses an S3 object based on the correct path for the default style" do + assert_equal("avatars/original/data", @dummy.avatar.s3_object.key) + end + + it "uses an S3 object based on the correct path for the custom style" do + assert_equal("avatars/thumb/data", @dummy.avatar.s3_object(:thumb).key) + end + end + + # the s3_host_name will be defined by the s3_region + context "s3_host_name" do + before do + rebuild_model storage: :s3, + s3_credentials: {}, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + s3_host_name: "s3-ap-northeast-1.amazonaws.com", + s3_region: "ap-northeast-1" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an :s3_host_name path" do + assert_match %r{^//s3-ap-northeast-1.amazonaws.com/bucket/avatars/data[^\.]}, @dummy.avatar.url + end + + it "uses the S3 bucket with the correct host name" do + assert_equal "s3.ap-northeast-1.amazonaws.com", + @dummy.avatar.s3_bucket.client.config.endpoint.host + end + end + + context "dynamic s3_host_name" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + s3_host_name: lambda {|a| a.instance.value } + @dummy = Dummy.new + class << @dummy + attr_accessor :value + end + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "uses s3_host_name as a proc if available" do + @dummy.value = "s3.something.com" + assert_equal "//s3.something.com/bucket/avatars/data", @dummy.avatar.url(:original, timestamp: false) + end + end + + context "use_accelerate_endpoint" do + context "defaults to false" do + before do + rebuild_model( + storage: :s3, + s3_credentials: {}, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + s3_host_name: "s3-ap-northeast-1.amazonaws.com", + s3_region: "ap-northeast-1", + ) + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an :s3_host_name path" do + assert_match %r{^//s3-ap-northeast-1.amazonaws.com/bucket/avatars/data[^\.]}, + @dummy.avatar.url + end + + it "uses the S3 client with the use_accelerate_endpoint config is false" do + expect(@dummy.avatar.s3_bucket.client.config.use_accelerate_endpoint).to be(false) + end + end + + context "set to true" do + before do + rebuild_model( + storage: :s3, + s3_credentials: {}, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + s3_host_name: "s3-accelerate.amazonaws.com", + s3_region: "ap-northeast-1", + use_accelerate_endpoint: true, + ) + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an :s3_host_name path" do + assert_match %r{^//s3-accelerate.amazonaws.com/bucket/avatars/data[^\.]}, + @dummy.avatar.url + end + + it "uses the S3 client with the use_accelerate_endpoint config is true" do + expect(@dummy.avatar.s3_bucket.client.config.use_accelerate_endpoint).to be(true) + end + end + end + + context "An attachment that uses S3 for storage and has styles that return different file types" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + styles: { large: ['500x500#', :jpg] }, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + } + + File.open(fixture_file('5k.png'), 'rb') do |file| + @dummy = Dummy.new + @dummy.avatar = file + @dummy.stubs(:new_record?).returns(false) + end + end + + it "returns a url containing the correct original file mime type" do + assert_match /.+\/5k.png/, @dummy.avatar.url + end + + it 'uses the correct key for the original file mime type' do + assert_match /.+\/5k.png/, @dummy.avatar.s3_object.key + end + + it "returns a url containing the correct processed file mime type" do + assert_match /.+\/5k.jpg/, @dummy.avatar.url(:large) + end + + it "uses the correct key for the processed file mime type" do + assert_match /.+\/5k.jpg/, @dummy.avatar.s3_object(:large).key + end + end + + context "An attachment that uses S3 for storage and has a proc for styles" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + styles: lambda { |attachment| attachment.instance.counter + {thumbnail: { geometry: "50x50#", + s3_headers: {'Cache-Control' => 'max-age=31557600'}} }}, + bucket: "bucket", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + } + + @file = File.new(fixture_file('5k.png'), 'rb') + + Dummy.class_eval do + def counter + @counter ||= 0 + @counter += 1 + @counter + end + end + + @dummy = Dummy.new + @dummy.avatar = @file + + object = stub + @dummy.avatar.stubs(:s3_object).with(:original).returns(object) + @dummy.avatar.stubs(:s3_object).with(:thumbnail).returns(object) + + object.expects(:upload_file) + .with(anything, content_type: 'image/png', + acl: :"public-read") + object.expects(:upload_file) + .with(anything, content_type: 'image/png', + acl: :"public-read", + cache_control: 'max-age=31557600') + @dummy.save + end + + after { @file.close } + + it "succeeds" do + assert_equal @dummy.counter, 7 + end + end + + context "An attachment that uses S3 for storage and has styles" do + before do + rebuild_model( + (aws2_add_region).merge( + storage: :s3, + styles: { thumb: ["90x90#", :jpg] }, + bucket: "bucket", + s3_credentials: { + "access_key_id" => "12345", + "secret_access_key" => "54321" } + ) + ) + + @file = File.new(fixture_file("5k.png"), "rb") + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.save + end + + context "reprocess" do + before do + @object = stub + @dummy.avatar.stubs(:s3_object).with(:original).returns(@object) + @dummy.avatar.stubs(:s3_object).with(:thumb).returns(@object) + @object.stubs(:get).yields(@file.read) + @object.stubs(:exists?).returns(true) + end + + it "uploads original" do + @object.expects(:upload_file).with( + anything, + content_type: "image/png", + acl: :"public-read").returns(true) + @object.expects(:upload_file).with( + anything, + content_type: "image/jpeg", + acl: :"public-read").returns(true) + @dummy.avatar.reprocess! + end + + it "doesn't upload original" do + @object.expects(:upload_file).with( + anything, + content_type: "image/jpeg", + acl: :"public-read").returns(true) + @dummy.avatar.reprocess!(:thumb) + end + end + + after { @file.close } + end + + context "An attachment that uses S3 for storage and has spaces in file name" do + before do + rebuild_model( + (aws2_add_region).merge storage: :s3, + styles: { large: ["500x500#", :jpg] }, + bucket: "bucket", + s3_credentials: { "access_key_id" => "12345", + "secret_access_key" => "54321" } + ) + + File.open(fixture_file("spaced file.png"), "rb") do |file| + @dummy = Dummy.new + @dummy.avatar = file + @dummy.stubs(:new_record?).returns(false) + end + end + + it "returns a replaced version for path" do + assert_match /.+\/spaced_file\.png/, @dummy.avatar.path + end + + it "returns a replaced version for url" do + assert_match /.+\/spaced_file\.png/, @dummy.avatar.url + end + end + + context "An attachment that uses S3 for storage and has a question mark in file name" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + styles: { large: ['500x500#', :jpg] }, + bucket: "bucket", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + } + + stringio = stringy_file + class << stringio + def original_filename + "question?mark.png" + end + end + file = Paperclip.io_adapters.for(stringio, hash_digest: Digest::MD5) + @dummy = Dummy.new + @dummy.avatar = file + @dummy.save + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a replaced version for path" do + assert_match /.+\/question_mark\.png/, @dummy.avatar.path + end + + it "returns a replaced version for url" do + assert_match /.+\/question_mark\.png/, @dummy.avatar.url + end + end + + context "" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + url: ":s3_domain_url" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on an S3 subdomain" do + assert_match %r{^//bucket.s3.amazonaws.com/avatars/data[^\.]}, @dummy.avatar.url + end + end + + context "" do + before do + rebuild_model( + (aws2_add_region).merge storage: :s3, + s3_credentials: { + production: { bucket: "prod_bucket" }, + development: { bucket: "dev_bucket" } + }, + bucket: "bucket", + s3_host_alias: "something.something.com", + path: ":attachment/:basename:dotextension", + url: ":s3_alias_url" + ) + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on the host_alias" do + assert_match %r{^//something.something.com/avatars/data[^\.]}, @dummy.avatar.url + end + end + + context "generating a url with a prefixed host alias" do + before do + rebuild_model( + aws2_add_region.merge( + storage: :s3, + s3_credentials: { + production: { bucket: "prod_bucket" }, + development: { bucket: "dev_bucket" }, + }, + bucket: "bucket", + s3_host_alias: "something.something.com", + s3_prefixes_in_alias: 2, + path: "prefix1/prefix2/:attachment/:basename:dotextension", + url: ":s3_alias_url", + ) + ) + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url with the prefixes removed" do + assert_match %r{^//something.something.com/avatars/data[^\.]}, + @dummy.avatar.url + end + end + + context "generating a url with a proc as the host alias" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: { bucket: "prod_bucket" }, + s3_host_alias: Proc.new{|atch| "cdn#{atch.instance.counter % 4}.example.com"}, + path: ":attachment/:basename:dotextension", + url: ":s3_alias_url" + Dummy.class_eval do + def counter + @counter ||= 0 + @counter += 1 + @counter + end + end + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a url based on the host_alias" do + assert_match %r{^//cdn1.example.com/avatars/data[^\.]}, @dummy.avatar.url + assert_match %r{^//cdn2.example.com/avatars/data[^\.]}, @dummy.avatar.url + end + + it "still returns the bucket name" do + assert_equal "prod_bucket", @dummy.avatar.bucket_name + end + + end + + context "" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: {}, + bucket: "bucket", + path: ":attachment/:basename:dotextension", + url: ":asset_host" + @dummy = Dummy.new + @dummy.avatar = stringy_file + @dummy.stubs(:new_record?).returns(false) + end + + it "returns a relative URL for Rails to calculate assets host" do + assert_match %r{^avatars/data[^\.]}, @dummy.avatar.url + end + + end + + context "Generating a secure url with an expiration" do + before do + @build_model_with_options = lambda {|options| + base_options = { + storage: :s3, + s3_credentials: { + production: { bucket: "prod_bucket" }, + development: { bucket: "dev_bucket" } + }, + s3_host_alias: "something.something.com", + s3_permissions: "private", + path: ":attachment/:basename:dotextension", + url: ":s3_alias_url" + } + + rebuild_model (aws2_add_region).merge base_options.merge(options) + } + end + + it "uses default options" do + @build_model_with_options[{}] + + rails_env("production") do + @dummy = Dummy.new + @dummy.avatar = stringy_file + + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:presigned_url).with(:get, expires_in: 3600) + @dummy.avatar.expiring_url + end + end + + it "allows overriding s3_url_options" do + @build_model_with_options[s3_url_options: { response_content_disposition: "inline" }] + + rails_env("production") do + @dummy = Dummy.new + @dummy.avatar = stringy_file + + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + object.expects(:presigned_url) + .with(:get, expires_in: 3600, + response_content_disposition: "inline") + @dummy.avatar.expiring_url + end + end + + it "allows overriding s3_object options with a proc" do + @build_model_with_options[s3_url_options: lambda {|attachment| { response_content_type: attachment.avatar_content_type } }] + + rails_env("production") do + @dummy = Dummy.new + + @file = stringy_file + @file.stubs(:original_filename).returns("5k.png\n\n") + Paperclip.stubs(:run).returns('image/png') + @file.stubs(:content_type).returns("image/png\n\n") + @file.stubs(:to_tempfile).returns(@file) + + @dummy.avatar = @file + + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + object.expects(:presigned_url) + .with(:get, expires_in: 3600, response_content_type: "image/png") + @dummy.avatar.expiring_url + end + end + end + + context "#expiring_url" do + before { @dummy = Dummy.new } + + context "with no attachment" do + before { assert(!@dummy.avatar.exists?) } + + it "returns the default URL" do + assert_equal(@dummy.avatar.url, @dummy.avatar.expiring_url) + end + + it 'generates a url for a style when a file does not exist' do + assert_equal(@dummy.avatar.url(:thumb), @dummy.avatar.expiring_url(3600, :thumb)) + end + end + + it "generates the same url when using Times and Integer offsets" do + assert_equal @dummy.avatar.expiring_url(1234), @dummy.avatar.expiring_url(Time.now + 1234) + end + end + + context "Generating a url with an expiration for each style" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: { + production: { bucket: "prod_bucket" }, + development: { bucket: "dev_bucket" } + }, + s3_permissions: :private, + s3_host_alias: "something.something.com", + path: ":attachment/:style/:basename:dotextension", + url: ":s3_alias_url" + + rails_env("production") do + @dummy = Dummy.new + @dummy.avatar = stringy_file + end + end + + it "generates a url for the thumb" do + object = stub + @dummy.avatar.stubs(:s3_object).with(:thumb).returns(object) + object.expects(:presigned_url).with(:get, expires_in: 1800) + @dummy.avatar.expiring_url(1800, :thumb) + end + + it "generates a url for the default style" do + object = stub + @dummy.avatar.stubs(:s3_object).with(:original).returns(object) + object.expects(:presigned_url).with(:get, expires_in: 1800) + @dummy.avatar.expiring_url(1800) + end + end + + context "Parsing S3 credentials with a bucket in them" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: { + production: { bucket: "prod_bucket" }, + development: { bucket: "dev_bucket" } + } + @dummy = Dummy.new + end + + it "gets the right bucket in production" do + rails_env("production") do + assert_equal "prod_bucket", @dummy.avatar.bucket_name + assert_equal "prod_bucket", @dummy.avatar.s3_bucket.name + end + end + + it "gets the right bucket in development" do + rails_env("development") do + assert_equal "dev_bucket", @dummy.avatar.bucket_name + assert_equal "dev_bucket", @dummy.avatar.s3_bucket.name + end + end + end + + # the bucket.name is determined by the :s3_region + context "Parsing S3 credentials with a s3_host_name in them" do + before do + rebuild_model storage: :s3, + bucket: 'testing', + s3_credentials: { + production: { + s3_region: "world-end", + s3_host_name: "s3-world-end.amazonaws.com" }, + development: { + s3_region: "ap-northeast-1", + s3_host_name: "s3-ap-northeast-1.amazonaws.com" }, + test: { + s3_region: "" } + } + @dummy = Dummy.new + end + + it "gets the right s3_host_name in production" do + rails_env("production") do + assert_match %r{^s3-world-end.amazonaws.com}, @dummy.avatar.s3_host_name + assert_match %r{^s3.world-end.amazonaws.com}, + @dummy.avatar.s3_bucket.client.config.endpoint.host + end + end + + it "gets the right s3_host_name in development" do + rails_env("development") do + assert_match %r{^s3.ap-northeast-1.amazonaws.com}, + @dummy.avatar.s3_host_name + assert_match %r{^s3.ap-northeast-1.amazonaws.com}, + @dummy.avatar.s3_bucket.client.config.endpoint.host + end + end + + it "gets the right s3_host_name if the key does not exist" do + rails_env("test") do + assert_match %r{^s3.amazonaws.com}, @dummy.avatar.s3_host_name + assert_raises(Aws::Errors::MissingRegionError) do + @dummy.avatar.s3_bucket.client.config.endpoint.host + end + end + end + end + + context "An attachment with S3 storage" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + access_key_id: "12345", + secret_access_key: "54321" + } + end + + it "is extended by the S3 module" do + assert Dummy.new.avatar.is_a?(Paperclip::Storage::S3) + end + + it "won't be extended by the Filesystem module" do + assert ! Dummy.new.avatar.is_a?(Paperclip::Storage::Filesystem) + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + @dummy.stubs(:new_record?).returns(false) + end + + after { @file.close } + + it "does not get a bucket to get a URL" do + @dummy.avatar.expects(:s3).never + @dummy.avatar.expects(:s3_bucket).never + assert_match %r{^//s3\.amazonaws\.com/testing/avatars/original/5k\.png}, @dummy.avatar.url + end + + it "is rewound after flush_writes" do + @dummy.avatar.instance_eval "def after_flush_writes; end" + @dummy.avatar.stubs(:s3_object).returns(stub(upload_file: true)) + files = @dummy.avatar.queued_for_write.values.each(&:read) + @dummy.save + assert files.none?(&:eof?), "Expect all the files to be rewound." + end + + it "is removed after after_flush_writes" do + @dummy.avatar.stubs(:s3_object).returns(stub(upload_file: true)) + paths = @dummy.avatar.queued_for_write.values.map(&:path) + @dummy.save + assert paths.none?{ |path| File.exist?(path) }, + "Expect all the files to be deleted." + end + + it "will retry to save again but back off on SlowDown" do + @dummy.avatar.stubs(:sleep) + Aws::S3::Object.any_instance.stubs(:upload_file). + raises(Aws::S3::Errors::SlowDown.new(stub, + stub(status: 503, body: ""))) + expect {@dummy.save}.to raise_error(Aws::S3::Errors::SlowDown) + expect(@dummy.avatar).to have_received(:sleep).with(1) + expect(@dummy.avatar).to have_received(:sleep).with(2) + expect(@dummy.avatar).to have_received(:sleep).with(4) + expect(@dummy.avatar).to have_received(:sleep).with(8) + expect(@dummy.avatar).to have_received(:sleep).with(16) + end + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + object.expects(:upload_file) + .with(anything, content_type: 'image/png', acl: :"public-read") + @dummy.save + end + + it "succeeds" do + assert true + end + end + + context "and saved without a bucket" do + before do + Aws::S3::Bucket.any_instance.expects(:create) + Aws::S3::Object.any_instance.stubs(:upload_file). + raises(Aws::S3::Errors::NoSuchBucket + .new(stub, + stub(status: 404, body: ""))).then.returns(nil) + @dummy.save + end + + it "succeeds" do + assert true + end + end + + context "and remove" do + before do + Aws::S3::Object.any_instance.stubs(:exists?).returns(true) + Aws::S3::Object.any_instance.stubs(:delete) + @dummy.destroy + end + + it "succeeds" do + assert true + end + end + + context 'that the file were missing' do + before do + Aws::S3::Object.any_instance.stubs(:exists?) + .raises(Aws::S3::Errors::ServiceError.new("rspec stub raises", + "object exists?")) + end + + it 'returns false on exists?' do + assert !@dummy.avatar.exists? + end + end + end + end + + context "An attachment with S3 storage and bucket defined as a Proc" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: lambda { |attachment| "bucket_#{attachment.instance.other}" }, + s3_credentials: {not: :important} + end + + it "gets the right bucket name" do + assert "bucket_a", Dummy.new(other: 'a').avatar.bucket_name + assert "bucket_a", Dummy.new(other: 'a').avatar.s3_bucket.name + assert "bucket_b", Dummy.new(other: 'b').avatar.bucket_name + assert "bucket_b", Dummy.new(other: 'b').avatar.s3_bucket.name + end + end + + context "An attachment with S3 storage and S3 credentials defined as a Proc" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: {not: :important}, + s3_credentials: lambda { |attachment| + Hash['access_key_id' => "access#{attachment.instance.other}", 'secret_access_key' => "secret#{attachment.instance.other}"] + } + end + + it "gets the right credentials" do + assert "access1234", Dummy.new(other: '1234').avatar.s3_credentials[:access_key_id] + assert "secret1234", Dummy.new(other: '1234').avatar.s3_credentials[:secret_access_key] + end + end + + context "An attachment with S3 storage and S3 credentials with a :credential_provider" do + before do + class DummyCredentialProvider; end + + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + s3_credentials: { + credentials: DummyCredentialProvider.new + } + @dummy = Dummy.new + end + + it "sets the credential-provider" do + expect(@dummy.avatar.s3_bucket.client.config.credentials).to be_a DummyCredentialProvider + end + end + + context "An attachment with S3 storage and S3 credentials in an unsupported manor" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", s3_credentials: ["unsupported"] + @dummy = Dummy.new + end + + it "does not accept the credentials" do + assert_raises(ArgumentError) do + @dummy.avatar.s3_credentials + end + end + end + + context "An attachment with S3 storage and S3 credentials not supplied" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, bucket: "testing" + @dummy = Dummy.new + end + + it "does not parse any credentials" do + assert_equal({}, @dummy.avatar.s3_credentials) + end + end + + context "An attachment with S3 storage and specific s3 headers set" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_headers: {'Cache-Control' => 'max-age=31557600'} + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, + content_type: 'image/png', + acl: :"public-read", + cache_control: 'max-age=31557600') + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "An attachment with S3 storage and metadata set using header names" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_headers: {'x-amz-meta-color' => 'red'} + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, + content_type: 'image/png', + acl: :"public-read", + metadata: { "color" => "red" }) + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "An attachment with S3 storage and metadata set using the :s3_metadata option" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_metadata: { "color" => "red" } + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, + content_type: 'image/png', + acl: :"public-read", + metadata: { "color" => "red" }) + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "An attachment with S3 storage and storage class set" do + context "using the header name" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_headers: { "x-amz-storage-class" => "reduced_redundancy" } + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, + content_type: 'image/png', + acl: :"public-read", + storage_class: "reduced_redundancy") + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "using per style hash" do + before do + rebuild_model (aws2_add_region).merge :storage => :s3, + :bucket => "testing", + :path => ":attachment/:style/:basename.:extension", + :styles => { + :thumb => "80x80>" + }, + :s3_credentials => { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + :s3_storage_class => { + :thumb => :reduced_redundancy + } + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + [:thumb, :original].each do |style| + @dummy.avatar.stubs(:s3_object).with(style).returns(object) + + expected_options = { + :content_type => "image/png", + acl: :"public-read" + } + expected_options.merge!(:storage_class => :reduced_redundancy) if style == :thumb + + object.expects(:upload_file) + .with(anything, expected_options) + end + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "using global hash option" do + before do + rebuild_model (aws2_add_region).merge :storage => :s3, + :bucket => "testing", + :path => ":attachment/:style/:basename.:extension", + :styles => { + :thumb => "80x80>" + }, + :s3_credentials => { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + :s3_storage_class => :reduced_redundancy + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + [:thumb, :original].each do |style| + @dummy.avatar.stubs(:s3_object).with(style).returns(object) + + object.expects(:upload_file) + .with(anything, :content_type => "image/png", + acl: :"public-read", + :storage_class => :reduced_redundancy) + end + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + end + + context "Can disable AES256 encryption multiple ways" do + [nil, false, ''].each do |tech| + before do + rebuild_model( + (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321"}, + s3_server_side_encryption: tech) + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, :content_type => "image/png", acl: :"public-read") + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + end + + context "An attachment with S3 storage and using AES256 encryption" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_server_side_encryption: "AES256" + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, content_type: "image/png", + acl: :"public-read", + server_side_encryption: "AES256") + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "An attachment with S3 storage and storage class set using the :storage_class option" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_storage_class: :reduced_redundancy + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, + content_type: "image/png", + acl: :"public-read", + storage_class: :reduced_redundancy) + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "with S3 credentials supplied as Pathname" do + before do + ENV['S3_KEY'] = 'pathname_key' + ENV['S3_BUCKET'] = 'pathname_bucket' + ENV['S3_SECRET'] = 'pathname_secret' + + rails_env('test') do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: Pathname.new(fixture_file('s3.yml')) + + Dummy.delete_all + @dummy = Dummy.new + end + end + + it "parses the credentials" do + assert_equal 'pathname_bucket', @dummy.avatar.bucket_name + + assert_equal 'pathname_key', + @dummy.avatar.s3_bucket.client.config.access_key_id + + assert_equal 'pathname_secret', + @dummy.avatar.s3_bucket.client.config.secret_access_key + end + end + + context "with S3 credentials in a YAML file" do + before do + ENV['S3_KEY'] = 'env_key' + ENV['S3_BUCKET'] = 'env_bucket' + ENV['S3_SECRET'] = 'env_secret' + + rails_env('test') do + rebuild_model (aws2_add_region).merge storage: :s3, + s3_credentials: File.new(fixture_file('s3.yml')) + + Dummy.delete_all + + @dummy = Dummy.new + end + end + + it "runs the file through ERB" do + assert_equal 'env_bucket', @dummy.avatar.bucket_name + + assert_equal 'env_key', + @dummy.avatar.s3_bucket.client.config.access_key_id + + assert_equal 'env_secret', + @dummy.avatar.s3_bucket.client.config.secret_access_key + end + end + + context "S3 Permissions" do + context "defaults to :public_read" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + } + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, content_type: "image/png", acl: :"public-read") + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "string permissions set" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_permissions: :private + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + object = stub + @dummy.avatar.stubs(:s3_object).returns(object) + + object.expects(:upload_file) + .with(anything, content_type: "image/png", acl: :private) + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "hash permissions set" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + styles: { + thumb: "80x80>" + }, + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_permissions: { + original: :private, + thumb: :public_read + } + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + [:thumb, :original].each do |style| + object = stub + @dummy.avatar.stubs(:s3_object).with(style).returns(object) + + object.expects(:upload_file) + .with(anything, + content_type: "image/png", + acl: style == :thumb ? :public_read : :private) + end + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "proc permission set" do + before do + rebuild_model( + (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + styles: { + thumb: "80x80>" + }, + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_permissions: lambda {|attachment, style| + attachment.instance.private_attachment? && style.to_sym != :thumb ? :private : :"public-read" + } + ) + end + end + end + + context "An attachment with S3 storage and metadata set using a proc as headers" do + before do + rebuild_model( + (aws2_add_region).merge storage: :s3, + bucket: "testing", + path: ":attachment/:style/:basename:dotextension", + styles: { + thumb: "80x80>" + }, + s3_credentials: { + 'access_key_id' => "12345", + 'secret_access_key' => "54321" + }, + s3_headers: lambda {|attachment| + {'Content-Disposition' => "attachment; filename=\"#{attachment.name}\""} + } + ) + end + + context "when assigned" do + before do + @file = File.new(fixture_file('5k.png'), 'rb') + @dummy = Dummy.new + @dummy.stubs(name: 'Custom Avatar Name.png') + @dummy.avatar = @file + end + + after { @file.close } + + context "and saved" do + before do + [:thumb, :original].each do |style| + object = stub + @dummy.avatar.stubs(:s3_object).with(style).returns(object) + + object.expects(:upload_file) + .with(anything, + content_type: "image/png", + acl: :"public-read", + content_disposition: 'attachment; filename="Custom Avatar Name.png"') + end + @dummy.save + end + + it "succeeds" do + assert true + end + end + end + end + + context "path is a proc" do + before do + rebuild_model (aws2_add_region).merge storage: :s3, + path: ->(attachment) { attachment.instance.attachment_path } + + @dummy = Dummy.new + @dummy.class_eval do + def attachment_path + '/some/dynamic/path' + end + end + @dummy.avatar = stringy_file + end + + it "returns a correct path" do + assert_match '/some/dynamic/path', @dummy.avatar.path + end + end + + private + + def rails_env(env) + stored_env, Rails.env = Rails.env, env + begin + yield + ensure + Rails.env = stored_env + end + end +end diff --git a/spec/paperclip/style_spec.rb b/spec/paperclip/style_spec.rb new file mode 100644 index 000000000..714c62e47 --- /dev/null +++ b/spec/paperclip/style_spec.rb @@ -0,0 +1,254 @@ +require 'spec_helper' + +describe Paperclip::Style do + context "A style rule" do + before do + @attachment = attachment path: ":basename.:extension", + styles: { foo: {geometry: "100x100#", format: :png} }, + whiny: true + @style = @attachment.styles[:foo] + end + + it "is held as a Style object" do + expect(@style).to be_a Paperclip::Style + end + + it "gets processors from the attachment definition" do + assert_equal [:thumbnail], @style.processors + end + + it "has the right geometry" do + assert_equal "100x100#", @style.geometry + end + + it "is whiny if the attachment is" do + assert @style.whiny? + end + + it "responds to hash notation" do + assert_equal [:thumbnail], @style[:processors] + assert_equal "100x100#", @style[:geometry] + end + + it "returns the name of the style in processor options" do + assert_equal :foo, @style.processor_options[:style] + end + end + + context "A style rule with properties supplied as procs" do + before do + @attachment = attachment path: ":basename.:extension", + whiny_thumbnails: true, + processors: lambda {|a| [:test]}, + styles: { + foo: lambda{|a| "300x300#"}, + bar: { + geometry: lambda{|a| "300x300#"}, + convert_options: lambda{|a| "-do_stuff"}, + source_file_options: lambda{|a| "-do_extra_stuff"} + } + } + end + + it "calls procs when they are needed" do + assert_equal "300x300#", @attachment.styles[:foo].geometry + assert_equal "300x300#", @attachment.styles[:bar].geometry + assert_equal [:test], @attachment.styles[:foo].processors + assert_equal [:test], @attachment.styles[:bar].processors + assert_equal "-do_stuff", @attachment.styles[:bar].convert_options + assert_equal "-do_extra_stuff", @attachment.styles[:bar].source_file_options + end + end + + context "An attachment with style rules in various forms" do + before do + styles = {} + styles[:aslist] = ["100x100", :png] + styles[:ashash] = {geometry: "100x100", format: :png} + styles[:asstring] = "100x100" + @attachment = attachment path: ":basename.:extension", + styles: styles + end + + it "has the right number of styles" do + expect(@attachment.styles).to be_a Hash + assert_equal 3, @attachment.styles.size + end + + it "has styles as Style objects" do + [:aslist, :ashash, :aslist].each do |s| + expect(@attachment.styles[s]).to be_a Paperclip::Style + end + end + + it "has the right geometries" do + [:aslist, :ashash, :aslist].each do |s| + assert_equal @attachment.styles[s].geometry, "100x100" + end + end + + it "has the right formats" do + assert_equal @attachment.styles[:aslist].format, :png + assert_equal @attachment.styles[:ashash].format, :png + assert_nil @attachment.styles[:asstring].format + end + + it "retains order" do + assert_equal [:aslist, :ashash, :asstring], @attachment.styles.keys + end + end + + context "An attachment with :convert_options" do + it "does not have called extra_options_for(:thumb/:large) on initialization" do + @attachment = attachment path: ":basename.:extension", + styles: {thumb: "100x100", large: "400x400"}, + convert_options: {all: "-do_stuff", thumb: "-thumbnailize"} + @attachment.expects(:extra_options_for).never + @style = @attachment.styles[:thumb] + end + + it "calls extra_options_for(:thumb/:large) when convert options are requested" do + @attachment = attachment path: ":basename.:extension", + styles: {thumb: "100x100", large: "400x400"}, + convert_options: {all: "-do_stuff", thumb: "-thumbnailize"} + @style = @attachment.styles[:thumb] + @file = StringIO.new("...") + @file.stubs(:original_filename).returns("file.jpg") + + @attachment.expects(:extra_options_for).with(:thumb) + @attachment.styles[:thumb].convert_options + end + end + + context "An attachment with :source_file_options" do + it "does not have called extra_source_file_options_for(:thumb/:large) on initialization" do + @attachment = attachment path: ":basename.:extension", + styles: {thumb: "100x100", large: "400x400"}, + source_file_options: {all: "-density 400", thumb: "-depth 8"} + @attachment.expects(:extra_source_file_options_for).never + @style = @attachment.styles[:thumb] + end + + it "calls extra_options_for(:thumb/:large) when convert options are requested" do + @attachment = attachment path: ":basename.:extension", + styles: {thumb: "100x100", large: "400x400"}, + source_file_options: {all: "-density 400", thumb: "-depth 8"} + @style = @attachment.styles[:thumb] + @file = StringIO.new("...") + @file.stubs(:original_filename).returns("file.jpg") + + @attachment.expects(:extra_source_file_options_for).with(:thumb) + @attachment.styles[:thumb].source_file_options + end + end + + context "A style rule with its own :processors" do + before do + @attachment = attachment path: ":basename.:extension", + styles: { + foo: { + geometry: "100x100#", + format: :png, + processors: [:test] + } + }, + processors: [:thumbnail] + @style = @attachment.styles[:foo] + end + + it "does not get processors from the attachment" do + @attachment.expects(:processors).never + assert_not_equal [:thumbnail], @style.processors + end + + it "reports its own processors" do + assert_equal [:test], @style.processors + end + + end + + context "A style rule with :processors supplied as procs" do + before do + @attachment = attachment path: ":basename.:extension", + styles: { + foo: { + geometry: "100x100#", + format: :png, + processors: lambda{|a| [:test]} + } + }, + processors: [:thumbnail] + end + + it "defers processing of procs until they are needed" do + expect(@attachment.styles[:foo].instance_variable_get("@processors")).to be_a Proc + end + + it "calls procs when they are needed" do + assert_equal [:test], @attachment.styles[:foo].processors + end + end + + context "An attachment with :convert_options and :source_file_options in :styles" do + before do + @attachment = attachment path: ":basename.:extension", + styles: { + thumb: "100x100", + large: {geometry: "400x400", + convert_options: "-do_stuff", + source_file_options: "-do_extra_stuff" + } + } + @file = StringIO.new("...") + @file.stubs(:original_filename).returns("file.jpg") + end + + it "has empty options for :thumb style" do + assert_equal "", @attachment.styles[:thumb].processor_options[:convert_options] + assert_equal "", @attachment.styles[:thumb].processor_options[:source_file_options] + end + + it "has the right options for :large style" do + assert_equal "-do_stuff", @attachment.styles[:large].processor_options[:convert_options] + assert_equal "-do_extra_stuff", @attachment.styles[:large].processor_options[:source_file_options] + end + end + + context "A style rule supplied with default format" do + before do + @attachment = attachment default_format: :png, + styles: { + asstring: "300x300#", + aslist: ["300x300#", :jpg], + ashash: { + geometry: "300x300#", + convert_options: "-do_stuff" + } + } + end + + it "has the right number of styles" do + expect(@attachment.styles).to be_a Hash + assert_equal 3, @attachment.styles.size + end + + it "has styles as Style objects" do + [:aslist, :ashash, :aslist].each do |s| + expect(@attachment.styles[s]).to be_a Paperclip::Style + end + end + + it "has the right geometries" do + [:aslist, :ashash, :aslist].each do |s| + assert_equal @attachment.styles[s].geometry, "300x300#" + end + end + + it "has the right formats" do + assert_equal @attachment.styles[:aslist].format, :jpg + assert_equal @attachment.styles[:ashash].format, :png + assert_equal @attachment.styles[:asstring].format, :png + end + + end +end diff --git a/spec/paperclip/tempfile_factory_spec.rb b/spec/paperclip/tempfile_factory_spec.rb new file mode 100644 index 000000000..3d246e03c --- /dev/null +++ b/spec/paperclip/tempfile_factory_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Paperclip::TempfileFactory do + it "is able to generate a tempfile with the right name" do + file = subject.generate("omg.png") + assert File.extname(file.path), "png" + end + + it "is able to generate a tempfile with the right name with a tilde at the beginning" do + file = subject.generate("~omg.png") + assert File.extname(file.path), "png" + end + + it "is able to generate a tempfile with the right name with a tilde at the end" do + file = subject.generate("omg.png~") + assert File.extname(file.path), "png" + end + + it "is able to generate a tempfile from a file with a really long name" do + filename = "#{"longfilename" * 100}.png" + file = subject.generate(filename) + assert File.extname(file.path), "png" + end + + it 'is able to take nothing as a parameter and not error' do + file = subject.generate + assert File.exist?(file.path) + end + + it "does not throw Errno::ENAMETOOLONG when it has a really long name" do + expect { subject.generate("o" * 255) }.to_not raise_error + end +end diff --git a/spec/paperclip/tempfile_spec.rb b/spec/paperclip/tempfile_spec.rb new file mode 100644 index 000000000..7e60eaec4 --- /dev/null +++ b/spec/paperclip/tempfile_spec.rb @@ -0,0 +1,35 @@ +require "spec_helper" + +describe Paperclip::Tempfile do + context "A Paperclip Tempfile" do + before do + @tempfile = described_class.new(["file", ".jpg"]) + end + + after { @tempfile.close } + + it "has its path contain a real extension" do + assert_equal ".jpg", File.extname(@tempfile.path) + end + + it "is a real Tempfile" do + assert @tempfile.is_a?(::Tempfile) + end + end + + context "Another Paperclip Tempfile" do + before do + @tempfile = described_class.new("file") + end + + after { @tempfile.close } + + it "does not have an extension if not given one" do + assert_equal "", File.extname(@tempfile.path) + end + + it "is a real Tempfile" do + assert @tempfile.is_a?(::Tempfile) + end + end +end diff --git a/spec/paperclip/thumbnail_spec.rb b/spec/paperclip/thumbnail_spec.rb new file mode 100644 index 000000000..aa42b993e --- /dev/null +++ b/spec/paperclip/thumbnail_spec.rb @@ -0,0 +1,505 @@ +require 'spec_helper' + +describe Paperclip::Thumbnail do + context "An image" do + before do + @file = File.new(fixture_file("5k.png"), 'rb') + end + + after { @file.close } + + [["600x600>", "434x66"], + ["400x400>", "400x61"], + ["32x32<", "434x66"], + [nil, "434x66"] + ].each do |args| + context "being thumbnailed with a geometry of #{args[0]}" do + before do + @thumb = Paperclip::Thumbnail.new(@file, geometry: args[0]) + end + + it "starts with dimensions of 434x66" do + cmd = %Q[identify -format "%wx%h" "#{@file.path}"] + assert_equal "434x66", `#{cmd}`.chomp + end + + it "reports the correct target geometry" do + assert_equal args[0].to_s, @thumb.target_geometry.to_s + end + + context "when made" do + before do + @thumb_result = @thumb.make + end + + it "is the size we expect it to be" do + cmd = %Q[identify -format "%wx%h" "#{@thumb_result.path}"] + assert_equal args[1], `#{cmd}`.chomp + end + end + end + end + + context "being thumbnailed at 100x50 with cropping" do + before do + @thumb = Paperclip::Thumbnail.new(@file, geometry: "100x50#") + end + + it "lets us know when a command isn't found versus a processing error" do + old_path = ENV['PATH'] + begin + Terrapin::CommandLine.path = '' + Paperclip.options[:command_path] = '' + ENV['PATH'] = '' + assert_raises(Paperclip::Errors::CommandNotFoundError) do + silence_stream(STDERR) do + @thumb.make + end + end + ensure + ENV['PATH'] = old_path + end + end + + it "reports its correct current and target geometries" do + assert_equal "100x50#", @thumb.target_geometry.to_s + assert_equal "434x66", @thumb.current_geometry.to_s + end + + it "reports its correct format" do + assert_nil @thumb.format + end + + it "has whiny turned on by default" do + assert @thumb.whiny + end + + it "has convert_options set to nil by default" do + assert_equal nil, @thumb.convert_options + end + + it "has source_file_options set to nil by default" do + assert_equal nil, @thumb.source_file_options + end + + it "sends the right command to convert when sent #make" do + @thumb.expects(:convert).with do |*arg| + arg[0] == ':source -auto-orient -resize "x50" -crop "100x50+114+0" +repage :dest' && + arg[1][:source] == "#{File.expand_path(@thumb.file.path)}[0]" + end + @thumb.make + end + + it "creates the thumbnail when sent #make" do + dst = @thumb.make + assert_match /100x50/, `identify "#{dst.path}"` + end + end + + it 'crops a EXIF-rotated image properly' do + file = File.new(fixture_file('rotated.jpg')) + thumb = Paperclip::Thumbnail.new(file, geometry: "50x50#") + + output_file = thumb.make + + command = Terrapin::CommandLine.new("identify", "-format %wx%h :file") + assert_equal "50x50", command.run(file: output_file.path).strip + end + + context "being thumbnailed with source file options set" do + before do + @thumb = Paperclip::Thumbnail.new(@file, + geometry: "100x50#", + source_file_options: "-strip") + end + + it "has source_file_options value set" do + assert_equal ["-strip"], @thumb.source_file_options + end + + it "sends the right command to convert when sent #make" do + @thumb.expects(:convert).with do |*arg| + arg[0] == '-strip :source -auto-orient -resize "x50" -crop "100x50+114+0" +repage :dest' && + arg[1][:source] == "#{File.expand_path(@thumb.file.path)}[0]" + end + @thumb.make + end + + it "creates the thumbnail when sent #make" do + dst = @thumb.make + assert_match /100x50/, `identify "#{dst.path}"` + end + + context "redefined to have bad source_file_options setting" do + before do + @thumb = Paperclip::Thumbnail.new(@file, + geometry: "100x50#", + source_file_options: "-this-aint-no-option") + end + + it "errors when trying to create the thumbnail" do + assert_raises(Paperclip::Error) do + silence_stream(STDERR) do + @thumb.make + end + end + end + end + end + + context "being thumbnailed with convert options set" do + before do + @thumb = Paperclip::Thumbnail.new(@file, + geometry: "100x50#", + convert_options: "-strip -depth 8") + end + + it "has convert_options value set" do + assert_equal %w"-strip -depth 8", @thumb.convert_options + end + + it "sends the right command to convert when sent #make" do + @thumb.expects(:convert).with do |*arg| + arg[0] == ':source -auto-orient -resize "x50" -crop "100x50+114+0" +repage -strip -depth 8 :dest' && + arg[1][:source] == "#{File.expand_path(@thumb.file.path)}[0]" + end + @thumb.make + end + + it "creates the thumbnail when sent #make" do + dst = @thumb.make + assert_match /100x50/, `identify "#{dst.path}"` + end + + context "redefined to have bad convert_options setting" do + before do + @thumb = Paperclip::Thumbnail.new(@file, + geometry: "100x50#", + convert_options: "-this-aint-no-option") + end + + it "errors when trying to create the thumbnail" do + silence_stream(STDERR) do + expect { + @thumb.make + }.to raise_error( + Paperclip::Error, /unrecognized option `-this-aint-no-option'/ + ) + end + end + + it "lets us know when a command isn't found versus a processing error" do + old_path = ENV['PATH'] + begin + Terrapin::CommandLine.path = '' + Paperclip.options[:command_path] = '' + ENV['PATH'] = '' + assert_raises(Paperclip::Errors::CommandNotFoundError) do + silence_stream(STDERR) do + @thumb.make + end + end + ensure + ENV['PATH'] = old_path + end + end + end + end + + context "being thumbnailed with a blank geometry string" do + before do + @thumb = Paperclip::Thumbnail.new(@file, + geometry: "", + convert_options: "-gravity center -crop \"300x300+0-0\"") + end + + it "does not get resized by default" do + assert !@thumb.transformation_command.include?("-resize") + end + end + + context "being thumbnailed with default animated option (true)" do + it "calls identify to check for animated images when sent #make" do + thumb = Paperclip::Thumbnail.new(@file, geometry: "100x50#") + thumb.expects(:identify).at_least_once.with do |*arg| + arg[0] == '-format %m :file' && + arg[1][:file] == "#{File.expand_path(thumb.file.path)}[0]" + end + thumb.make + end + end + + context "passing a custom file geometry parser" do + after do + Object.send(:remove_const, :GeoParser) if Object.const_defined?(:GeoParser) + end + + it "produces the appropriate transformation_command" do + GeoParser = Class.new do + def self.from_file(file) + new + end + + def transformation_to(target, should_crop) + ["SCALE", "CROP"] + end + end + + thumb = Paperclip::Thumbnail.new(@file, geometry: '50x50', file_geometry_parser: ::GeoParser) + + transformation_command = thumb.transformation_command + + assert transformation_command.include?('-crop'), + %{expected #{transformation_command.inspect} to include '-crop'} + assert transformation_command.include?('"CROP"'), + %{expected #{transformation_command.inspect} to include '"CROP"'} + assert transformation_command.include?('-resize'), + %{expected #{transformation_command.inspect} to include '-resize'} + assert transformation_command.include?('"SCALE"'), + %{expected #{transformation_command.inspect} to include '"SCALE"'} + end + end + + context "passing a custom geometry string parser" do + after do + Object.send(:remove_const, :GeoParser) if Object.const_defined?(:GeoParser) + end + + it "produces the appropriate transformation_command" do + GeoParser = Class.new do + def self.parse(s) + new + end + + def to_s + "151x167" + end + end + + thumb = Paperclip::Thumbnail.new(@file, geometry: '50x50', string_geometry_parser: ::GeoParser) + + transformation_command = thumb.transformation_command + + assert transformation_command.include?('"151x167"'), + %{expected #{transformation_command.inspect} to include '151x167'} + end + end + end + + context "A multipage PDF" do + before do + @file = File.new(fixture_file("twopage.pdf"), 'rb') + end + + after { @file.close } + + it "starts with two pages with dimensions 612x792" do + cmd = %Q[identify -format "%wx%h" "#{@file.path}"] + assert_equal "612x792"*2, `#{cmd}`.chomp + end + + context "being thumbnailed at 100x100 with cropping" do + before do + @thumb = Paperclip::Thumbnail.new(@file, geometry: "100x100#", format: :png) + end + + it "reports its correct current and target geometries" do + assert_equal "100x100#", @thumb.target_geometry.to_s + assert_equal "612x792", @thumb.current_geometry.to_s + end + + it "reports its correct format" do + assert_equal :png, @thumb.format + end + + it "creates the thumbnail when sent #make" do + dst = @thumb.make + assert_match /100x100/, `identify "#{dst.path}"` + end + end + end + + context "An animated gif" do + before do + @file = File.new(fixture_file("animated.gif"), 'rb') + end + + after { @file.close } + + it "starts with 12 frames with size 100x100" do + cmd = %Q[identify -format "%wx%h" "#{@file.path}"] + assert_equal "100x100"*12, `#{cmd}`.chomp + end + + context "with static output" do + before do + @thumb = Paperclip::Thumbnail.new(@file, geometry: "50x50", format: :jpg) + end + + it "creates the single frame thumbnail when sent #make" do + dst = @thumb.make + cmd = %Q[identify -format "%wx%h" "#{dst.path}"] + assert_equal "50x50", `#{cmd}`.chomp + end + end + + context "with animated output format" do + before do + @thumb = Paperclip::Thumbnail.new(@file, geometry: "50x50", format: :gif) + end + + it "creates the 12 frames thumbnail when sent #make" do + dst = @thumb.make + cmd = %Q[identify -format "%wx%h," "#{dst.path}"] + frames = `#{cmd}`.chomp.split(',') + assert_equal 12, frames.size + assert_frame_dimensions (45..50), frames + end + + it "uses the -coalesce option" do + assert_equal @thumb.transformation_command.first, "-coalesce" + end + + it "uses the -layers 'optimize' option" do + assert_equal @thumb.transformation_command.last, '-layers "optimize"' + end + end + + context "with omitted output format" do + before do + @thumb = Paperclip::Thumbnail.new(@file, geometry: "50x50") + end + + it "creates the 12 frames thumbnail when sent #make" do + dst = @thumb.make + cmd = %Q[identify -format "%wx%h," "#{dst.path}"] + frames = `#{cmd}`.chomp.split(',') + assert_equal 12, frames.size + assert_frame_dimensions (45..50), frames + end + + it "uses the -coalesce option" do + assert_equal @thumb.transformation_command.first, "-coalesce" + end + + it "uses the -layers 'optimize' option" do + assert_equal @thumb.transformation_command.last, '-layers "optimize"' + end + end + + context "with unidentified source format" do + before do + @unidentified_file = File.new(fixture_file("animated.unknown"), 'rb') + @thumb = Paperclip::Thumbnail.new(@file, geometry: "60x60") + end + + it "creates the 12 frames thumbnail when sent #make" do + dst = @thumb.make + cmd = %Q[identify -format "%wx%h," "#{dst.path}"] + frames = `#{cmd}`.chomp.split(',') + assert_equal 12, frames.size + assert_frame_dimensions (55..60), frames + end + + it "uses the -coalesce option" do + assert_equal @thumb.transformation_command.first, "-coalesce" + end + + it "uses the -layers 'optimize' option" do + assert_equal @thumb.transformation_command.last, '-layers "optimize"' + end + end + + context "with no source format" do + before do + @unidentified_file = File.new(fixture_file("animated"), 'rb') + @thumb = Paperclip::Thumbnail.new(@file, geometry: "70x70") + end + + it "creates the 12 frames thumbnail when sent #make" do + dst = @thumb.make + cmd = %Q[identify -format "%wx%h," "#{dst.path}"] + frames = `#{cmd}`.chomp.split(',') + assert_equal 12, frames.size + assert_frame_dimensions (60..70), frames + end + + it "uses the -coalesce option" do + assert_equal @thumb.transformation_command.first, "-coalesce" + end + + it "uses the -layers 'optimize' option" do + assert_equal @thumb.transformation_command.last, '-layers "optimize"' + end + end + + context "with animated option set to false" do + before do + @thumb = Paperclip::Thumbnail.new(@file, geometry: "50x50", animated: false) + end + + it "outputs the gif format" do + dst = @thumb.make + cmd = %Q[identify "#{dst.path}"] + assert_match /GIF/, `#{cmd}`.chomp + end + + it "creates the single frame thumbnail when sent #make" do + dst = @thumb.make + cmd = %Q[identify -format "%wx%h" "#{dst.path}"] + assert_equal "50x50", `#{cmd}`.chomp + end + end + + context "with a specified frame_index" do + before do + @thumb = Paperclip::Thumbnail.new( + @file, + geometry: "50x50", + frame_index: 5, + format: :jpg, + ) + end + + it "creates the thumbnail from the frame index when sent #make" do + @thumb.make + assert_equal 5, @thumb.frame_index + end + end + + context "with a specified frame_index out of bounds" do + before do + @thumb = Paperclip::Thumbnail.new( + @file, + geometry: "50x50", + frame_index: 20, + format: :jpg, + ) + end + + it "errors when trying to create the thumbnail" do + assert_raises(Paperclip::Error) do + silence_stream(STDERR) do + @thumb.make + end + end + end + end + end + + context "with a really long file name" do + before do + tempfile = Tempfile.new("f") + tempfile_additional_chars = tempfile.path.split("/")[-1].length + 15 + image_file = File.new(fixture_file("5k.png"), "rb") + @file = Tempfile.new("f" * (255 - tempfile_additional_chars)) + @file.write(image_file.read) + @file.rewind + end + + it "does not throw Errno::ENAMETOOLONG" do + thumb = Paperclip::Thumbnail.new(@file, geometry: "50x50", format: :gif) + expect { thumb.make }.to_not raise_error + end + end +end diff --git a/spec/paperclip/url_generator_spec.rb b/spec/paperclip/url_generator_spec.rb new file mode 100644 index 000000000..29fd9f78b --- /dev/null +++ b/spec/paperclip/url_generator_spec.rb @@ -0,0 +1,221 @@ +require 'spec_helper' + +describe Paperclip::UrlGenerator do + it "uses the given interpolator" do + expected = "the expected result" + mock_interpolator = MockInterpolator.new(result: expected) + mock_attachment = MockAttachment.new(interpolator: mock_interpolator) + + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + result = url_generator.for(:style_name, {}) + + assert_equal expected, result + assert mock_interpolator.has_interpolated_attachment?(mock_attachment) + assert mock_interpolator.has_interpolated_style_name?(:style_name) + end + + it "uses the default URL when no file is assigned" do + mock_interpolator = MockInterpolator.new + default_url = "the default url" + options = { interpolator: mock_interpolator, default_url: default_url } + mock_attachment = MockAttachment.new(options) + + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + url_generator.for(:style_name, {}) + + assert mock_interpolator.has_interpolated_pattern?(default_url), + "expected the interpolator to be passed #{default_url.inspect} but it wasn't" + end + + it "executes the default URL lambda when no file is assigned" do + mock_interpolator = MockInterpolator.new + default_url = lambda {|attachment| "the #{attachment.class.name} default url" } + options = { interpolator: mock_interpolator, default_url: default_url} + mock_attachment = MockAttachment.new(options) + + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + url_generator.for(:style_name, {}) + + assert mock_interpolator.has_interpolated_pattern?("the MockAttachment default url"), + %{expected the interpolator to be passed "the MockAttachment default url", but it wasn't} + end + + it "executes the method named by the symbol as the default URL when no file is assigned" do + mock_model = FakeModel.new + default_url = :to_s + mock_interpolator = MockInterpolator.new + options = { + interpolator: mock_interpolator, + default_url: default_url, + model: mock_model, + } + mock_attachment = MockAttachment.new(options) + + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + url_generator.for(:style_name, {}) + + assert mock_interpolator.has_interpolated_pattern?(mock_model.to_s), + %{expected the interpolator to be passed #{mock_model.to_s}, but it wasn't} + end + + it "URL-escapes spaces if asked to" do + expected = "the expected result" + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {escape: true}) + + assert_equal "the%20expected%20result", result + end + + it "escapes the result of the interpolator using a method on the object, if asked to escape" do + expected = Class.new do + def escape + "the escaped result" + end + end.new + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {escape: true}) + + assert_equal "the escaped result", result + end + + it "leaves spaces unescaped as asked to" do + expected = "the expected result" + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {escape: false}) + + assert_equal "the expected result", result + end + + it "defaults to leaving spaces unescaped" do + expected = "the expected result" + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {}) + + assert_equal "the expected result", result + end + + it "produces URLs without the updated_at value when the object does not respond to updated_at" do + expected = "the expected result" + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator, responds_to_updated_at: false } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {timestamp: true}) + + assert_equal expected, result + end + + it "produces URLs without the updated_at value when the updated_at value is nil" do + expected = "the expected result" + mock_interpolator = MockInterpolator.new(result: expected) + options = { + responds_to_updated_at: true, + updated_at: nil, + interpolator: mock_interpolator, + } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {timestamp: true}) + + assert_equal expected, result + end + + it "produces URLs with the updated_at when it exists" do + expected = "the expected result" + updated_at = 1231231234 + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator, updated_at: updated_at } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {timestamp: true}) + + assert_equal "#{expected}?#{updated_at}", result + end + + it "produces URLs with the updated_at when it exists, separated with a & if a ? follow by = already exists" do + expected = "the?expected=result" + updated_at = 1231231234 + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator, updated_at: updated_at } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {timestamp: true}) + + assert_equal "#{expected}&#{updated_at}", result + end + + it "produces URLs without the updated_at when told to do as much" do + expected = "the expected result" + updated_at = 1231231234 + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator, updated_at: updated_at } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + + result = url_generator.for(:style_name, {timestamp: false}) + + assert_equal expected, result + end + + it "produces the correct URL when the instance has a file name" do + expected = "the expected result" + mock_interpolator = MockInterpolator.new + options = { + interpolator: mock_interpolator, + url: expected, + original_filename: "exists", + } + mock_attachment = MockAttachment.new(options) + + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + url_generator.for(:style_name, {}) + + assert mock_interpolator.has_interpolated_pattern?(expected), + "expected the interpolator to be passed #{expected.inspect} but it wasn't" + end + + describe "should be able to escape (, ), [, and ]." do + def generate(expected, updated_at=nil) + mock_interpolator = MockInterpolator.new(result: expected) + options = { interpolator: mock_interpolator, updated_at: updated_at } + mock_attachment = MockAttachment.new(options) + url_generator = Paperclip::UrlGenerator.new(mock_attachment) + def url_generator.respond_to(params) + false if params == :escape + end + url_generator.for(:style_name, {escape: true, timestamp: !!updated_at}) + end + + it "not timestamp" do + expected = "the(expected)result[]" + assert_equal "the%28expected%29result%5B%5D", generate(expected) + end + + it "timestamp" do + expected = "the(expected)result[]" + updated_at = 1231231234 + assert_equal "the%28expected%29result%5B%5D?#{updated_at}", + generate(expected, updated_at) + end + end +end diff --git a/spec/paperclip/validators/attachment_content_type_validator_spec.rb b/spec/paperclip/validators/attachment_content_type_validator_spec.rb new file mode 100644 index 000000000..b513b6121 --- /dev/null +++ b/spec/paperclip/validators/attachment_content_type_validator_spec.rb @@ -0,0 +1,322 @@ +require 'spec_helper' + +describe Paperclip::Validators::AttachmentContentTypeValidator do + before do + rebuild_model + @dummy = Dummy.new + end + + def build_validator(options) + @validator = Paperclip::Validators::AttachmentContentTypeValidator.new(options.merge( + attributes: :avatar + )) + end + + context "with a nil content type" do + before do + build_validator content_type: "image/jpg" + @dummy.stubs(avatar_content_type: nil) + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + + context "with :allow_nil option" do + context "as true" do + before do + build_validator content_type: "image/png", allow_nil: true + @dummy.stubs(avatar_content_type: nil) + @validator.validate(@dummy) + end + + it "allows avatar_content_type as nil" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + + context "as false" do + before do + build_validator content_type: "image/png", allow_nil: false + @dummy.stubs(avatar_content_type: nil) + @validator.validate(@dummy) + end + + it "does not allow avatar_content_type as nil" do + assert @dummy.errors[:avatar_content_type].present? + end + end + end + + context "with a failing validation" do + before do + build_validator content_type: "image/png", allow_nil: false + @dummy.stubs(avatar_content_type: nil) + @validator.validate(@dummy) + end + + it "adds error to the base object" do + assert @dummy.errors[:avatar].present?, + "Error not added to base attribute" + end + + it "adds error to base object as a string" do + expect(@dummy.errors[:avatar].first).to be_a String + end + end + + context "with a successful validation" do + before do + build_validator content_type: "image/png", allow_nil: false + @dummy.stubs(avatar_content_type: "image/png") + @validator.validate(@dummy) + end + + it "does not add error to the base object" do + assert @dummy.errors[:avatar].blank?, + "Error was added to base attribute" + end + end + + context "with :allow_blank option" do + context "as true" do + before do + build_validator content_type: "image/png", allow_blank: true + @dummy.stubs(avatar_content_type: "") + @validator.validate(@dummy) + end + + it "allows avatar_content_type as blank" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + + context "as false" do + before do + build_validator content_type: "image/png", allow_blank: false + @dummy.stubs(avatar_content_type: "") + @validator.validate(@dummy) + end + + it "does not allow avatar_content_type as blank" do + assert @dummy.errors[:avatar_content_type].present? + end + end + end + + context "whitelist format" do + context "with an allowed type" do + context "as a string" do + before do + build_validator content_type: "image/jpg" + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + + context "as an regexp" do + before do + build_validator content_type: /^image\/.*/ + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + + context "as a list" do + before do + build_validator content_type: ["image/png", "image/jpg", "image/jpeg"] + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + end + + context "with a disallowed type" do + context "as a string" do + before do + build_validator content_type: "image/png" + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "sets a correct default error message" do + assert @dummy.errors[:avatar_content_type].present? + expect(@dummy.errors[:avatar_content_type]).to include "is invalid" + end + end + + context "as a regexp" do + before do + build_validator content_type: /^text\/.*/ + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "sets a correct default error message" do + assert @dummy.errors[:avatar_content_type].present? + expect(@dummy.errors[:avatar_content_type]).to include "is invalid" + end + end + + context "with :message option" do + context "without interpolation" do + before do + build_validator content_type: "image/png", message: "should be a PNG image" + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "sets a correct error message" do + expect(@dummy.errors[:avatar_content_type]).to include "should be a PNG image" + end + end + + context "with interpolation" do + before do + build_validator content_type: "image/png", message: "should have content type %{types}" + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "sets a correct error message" do + expect(@dummy.errors[:avatar_content_type]).to include "should have content type image/png" + end + end + end + end + end + + context "blacklist format" do + context "with an allowed type" do + context "as a string" do + before do + build_validator not: "image/gif" + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + + context "as an regexp" do + before do + build_validator not: /^text\/.*/ + @dummy.stubs(avatar_content_type: "image/jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + + context "as a list" do + before do + build_validator not: ["image/png", "image/jpg", "image/jpeg"] + @dummy.stubs(avatar_content_type: "image/gif") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_content_type].blank? + end + end + end + + context "with a disallowed type" do + context "as a string" do + before do + build_validator not: "image/png" + @dummy.stubs(avatar_content_type: "image/png") + @validator.validate(@dummy) + end + + it "sets a correct default error message" do + assert @dummy.errors[:avatar_content_type].present? + expect(@dummy.errors[:avatar_content_type]).to include "is invalid" + end + end + + context "as a regexp" do + before do + build_validator not: /^text\/.*/ + @dummy.stubs(avatar_content_type: "text/plain") + @validator.validate(@dummy) + end + + it "sets a correct default error message" do + assert @dummy.errors[:avatar_content_type].present? + expect(@dummy.errors[:avatar_content_type]).to include "is invalid" + end + end + + context "with :message option" do + context "without interpolation" do + before do + build_validator not: "image/png", message: "should not be a PNG image" + @dummy.stubs(avatar_content_type: "image/png") + @validator.validate(@dummy) + end + + it "sets a correct error message" do + expect(@dummy.errors[:avatar_content_type]).to include "should not be a PNG image" + end + end + + context "with interpolation" do + before do + build_validator not: "image/png", message: "should not have content type %{types}" + @dummy.stubs(avatar_content_type: "image/png") + @validator.validate(@dummy) + end + + it "sets a correct error message" do + expect(@dummy.errors[:avatar_content_type]).to include "should not have content type image/png" + end + end + end + end + end + + context "using the helper" do + before do + Dummy.validates_attachment_content_type :avatar, content_type: "image/jpg" + end + + it "adds the validator to the class" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_content_type } + end + end + + context "given options" do + it "raises argument error if no required argument was given" do + assert_raises(ArgumentError) do + build_validator message: "Some message" + end + end + + it "does not raise argument error if :content_type was given" do + build_validator content_type: "image/jpg" + end + + it "does not raise argument error if :not was given" do + build_validator not: "image/jpg" + end + end +end diff --git a/spec/paperclip/validators/attachment_file_name_validator_spec.rb b/spec/paperclip/validators/attachment_file_name_validator_spec.rb new file mode 100644 index 000000000..07e799248 --- /dev/null +++ b/spec/paperclip/validators/attachment_file_name_validator_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +describe Paperclip::Validators::AttachmentFileNameValidator do + before do + rebuild_model + @dummy = Dummy.new + end + + def build_validator(options) + @validator = Paperclip::Validators::AttachmentFileNameValidator.new(options.merge( + attributes: :avatar + )) + end + + context "with a failing validation" do + before do + build_validator matches: /.*\.png$/, allow_nil: false + @dummy.stubs(avatar_file_name: "data.txt") + @validator.validate(@dummy) + end + + it "adds error to the base object" do + assert @dummy.errors[:avatar].present?, + "Error not added to base attribute" + end + + it "adds error to base object as a string" do + expect(@dummy.errors[:avatar].first).to be_a String + end + end + + it "does not add error to the base object with a successful validation" do + build_validator matches: /.*\.png$/, allow_nil: false + @dummy.stubs(avatar_file_name: "image.png") + @validator.validate(@dummy) + + assert @dummy.errors[:avatar].blank?, "Error was added to base attribute" + end + + context "whitelist format" do + context "with an allowed type" do + context "as a single regexp" do + before do + build_validator matches: /.*\.jpg$/ + @dummy.stubs(avatar_file_name: "image.jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_file_name].blank? + end + end + + context "as a list" do + before do + build_validator matches: [/.*\.png$/, /.*\.jpe?g$/] + @dummy.stubs(avatar_file_name: "image.jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_file_name].blank? + end + end + end + + context "with a disallowed type" do + it "sets a correct default error message" do + build_validator matches: /^text\/.*/ + @dummy.stubs(avatar_file_name: "image.jpg") + @validator.validate(@dummy) + + assert @dummy.errors[:avatar_file_name].present? + expect(@dummy.errors[:avatar_file_name]).to include "is invalid" + end + + it "sets a correct custom error message" do + build_validator matches: /.*\.png$/, message: "should be a PNG image" + @dummy.stubs(avatar_file_name: "image.jpg") + @validator.validate(@dummy) + + expect(@dummy.errors[:avatar_file_name]).to include "should be a PNG image" + end + end + end + + context "blacklist format" do + context "with an allowed type" do + context "as a single regexp" do + before do + build_validator not: /^text\/.*/ + @dummy.stubs(avatar_file_name: "image.jpg") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_file_name].blank? + end + end + + context "as a list" do + before do + build_validator not: [/.*\.png$/, /.*\.jpe?g$/] + @dummy.stubs(avatar_file_name: "image.gif") + @validator.validate(@dummy) + end + + it "does not set an error message" do + assert @dummy.errors[:avatar_file_name].blank? + end + end + end + + context "with a disallowed type" do + it "sets a correct default error message" do + build_validator not: /data.*/ + @dummy.stubs(avatar_file_name: "data.txt") + @validator.validate(@dummy) + + assert @dummy.errors[:avatar_file_name].present? + expect(@dummy.errors[:avatar_file_name]).to include "is invalid" + end + + it "sets a correct custom error message" do + build_validator not: /.*\.png$/, message: "should not be a PNG image" + @dummy.stubs(avatar_file_name: "image.png") + @validator.validate(@dummy) + + expect(@dummy.errors[:avatar_file_name]).to include "should not be a PNG image" + end + end + end + + context "using the helper" do + before do + Dummy.validates_attachment_file_name :avatar, matches: /.*\.jpg$/ + end + + it "adds the validator to the class" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_file_name } + end + end + + context "given options" do + it "raises argument error if no required argument was given" do + assert_raises(ArgumentError) do + build_validator message: "Some message" + end + end + + it "does not raise argument error if :matches was given" do + build_validator matches: /.*\.jpg$/ + end + + it "does not raise argument error if :not was given" do + build_validator not: /.*\.jpg$/ + end + end +end + diff --git a/spec/paperclip/validators/attachment_presence_validator_spec.rb b/spec/paperclip/validators/attachment_presence_validator_spec.rb new file mode 100644 index 000000000..df407275e --- /dev/null +++ b/spec/paperclip/validators/attachment_presence_validator_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe Paperclip::Validators::AttachmentPresenceValidator do + before do + rebuild_model + @dummy = Dummy.new + end + + def build_validator(options={}) + @validator = Paperclip::Validators::AttachmentPresenceValidator.new(options.merge( + attributes: :avatar + )) + end + + context "nil attachment" do + before do + @dummy.avatar = nil + end + + context "with default options" do + before do + build_validator + @validator.validate(@dummy) + end + + it "adds error on the attachment" do + assert @dummy.errors[:avatar].present? + end + + it "does not add an error on the file_name attribute" do + assert @dummy.errors[:avatar_file_name].blank? + end + end + + context "with :if option" do + context "returning true" do + before do + build_validator if: true + @validator.validate(@dummy) + end + + it "performs a validation" do + assert @dummy.errors[:avatar].present? + end + end + + context "returning false" do + before do + build_validator if: false + @validator.validate(@dummy) + end + + it "performs a validation" do + assert @dummy.errors[:avatar].present? + end + end + end + end + + context "with attachment" do + before do + build_validator + @dummy.avatar = StringIO.new('.\n') + @validator.validate(@dummy) + end + + it "does not add error on the attachment" do + assert @dummy.errors[:avatar].blank? + end + + it "does not add an error on the file_name attribute" do + assert @dummy.errors[:avatar_file_name].blank? + end + end + + context "using the helper" do + before do + Dummy.validates_attachment_presence :avatar + end + + it "adds the validator to the class" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_presence } + end + end +end diff --git a/spec/paperclip/validators/attachment_size_validator_spec.rb b/spec/paperclip/validators/attachment_size_validator_spec.rb new file mode 100644 index 000000000..b2d375fea --- /dev/null +++ b/spec/paperclip/validators/attachment_size_validator_spec.rb @@ -0,0 +1,235 @@ +require 'spec_helper' + +describe Paperclip::Validators::AttachmentSizeValidator do + before do + rebuild_model + @dummy = Dummy.new + end + + def build_validator(options) + @validator = Paperclip::Validators::AttachmentSizeValidator.new(options.merge( + attributes: :avatar + )) + end + + def self.should_allow_attachment_file_size(size) + context "when the attachment size is #{size}" do + it "adds error to dummy object" do + @dummy.stubs(:avatar_file_size).returns(size) + @validator.validate(@dummy) + assert @dummy.errors[:avatar_file_size].blank?, + "Expect an error message on :avatar_file_size, got none." + end + + it "does not add error to the base dummy object" do + assert @dummy.errors[:avatar].blank?, + "Error added to base attribute" + end + end + end + + def self.should_not_allow_attachment_file_size(size, options = {}) + context "when the attachment size is #{size}" do + before do + @dummy.stubs(:avatar_file_size).returns(size) + @validator.validate(@dummy) + end + + it "adds error to dummy object" do + assert @dummy.errors[:avatar_file_size].present?, + "Unexpected error message on :avatar_file_size" + end + + it "adds error to the base dummy object" do + assert @dummy.errors[:avatar].present?, + "Error not added to base attribute" + end + + it "adds error to base object as a string" do + expect(@dummy.errors[:avatar].first).to be_a String + end + + if options[:message] + it "returns a correct error message" do + expect(@dummy.errors[:avatar_file_size]).to include options[:message] + end + end + end + end + + context "with :in option" do + context "as a range" do + before do + build_validator in: (5.kilobytes..10.kilobytes) + end + + should_allow_attachment_file_size(7.kilobytes) + should_not_allow_attachment_file_size(4.kilobytes) + should_not_allow_attachment_file_size(11.kilobytes) + end + + context "as a proc" do + before do + build_validator in: lambda { |avatar| (5.kilobytes..10.kilobytes) } + end + + should_allow_attachment_file_size(7.kilobytes) + should_not_allow_attachment_file_size(4.kilobytes) + should_not_allow_attachment_file_size(11.kilobytes) + end + end + + context "with :greater_than option" do + context "as number" do + before do + build_validator greater_than: 10.kilobytes + end + + should_allow_attachment_file_size 11.kilobytes + should_not_allow_attachment_file_size 10.kilobytes + end + + context "as a proc" do + before do + build_validator greater_than: lambda { |avatar| 10.kilobytes } + end + + should_allow_attachment_file_size 11.kilobytes + should_not_allow_attachment_file_size 10.kilobytes + end + end + + context "with :less_than option" do + context "as number" do + before do + build_validator less_than: 10.kilobytes + end + + should_allow_attachment_file_size 9.kilobytes + should_not_allow_attachment_file_size 10.kilobytes + end + + context "as a proc" do + before do + build_validator less_than: lambda { |avatar| 10.kilobytes } + end + + should_allow_attachment_file_size 9.kilobytes + should_not_allow_attachment_file_size 10.kilobytes + end + end + + context "with :greater_than and :less_than option" do + context "as numbers" do + before do + build_validator greater_than: 5.kilobytes, + less_than: 10.kilobytes + end + + should_allow_attachment_file_size 7.kilobytes + should_not_allow_attachment_file_size 5.kilobytes + should_not_allow_attachment_file_size 10.kilobytes + end + + context "as a proc" do + before do + build_validator greater_than: lambda { |avatar| 5.kilobytes }, + less_than: lambda { |avatar| 10.kilobytes } + end + + should_allow_attachment_file_size 7.kilobytes + should_not_allow_attachment_file_size 5.kilobytes + should_not_allow_attachment_file_size 10.kilobytes + end + end + + context "with :message option" do + context "given a range" do + before do + build_validator in: (5.kilobytes..10.kilobytes), + message: "is invalid. (Between %{min} and %{max} please.)" + end + + should_not_allow_attachment_file_size( + 11.kilobytes, + message: "is invalid. (Between 5 KB and 10 KB please.)" + ) + end + + context "given :less_than and :greater_than" do + before do + build_validator less_than: 10.kilobytes, + greater_than: 5.kilobytes, + message: "is invalid. (Between %{min} and %{max} please.)" + end + + should_not_allow_attachment_file_size( + 11.kilobytes, + message: "is invalid. (Between 5 KB and 10 KB please.)" + ) + end + end + + context "default error messages" do + context "given :less_than and :greater_than" do + before do + build_validator greater_than: 5.kilobytes, + less_than: 10.kilobytes + end + + should_not_allow_attachment_file_size( + 11.kilobytes, + message: "must be less than 10 KB" + ) + + should_not_allow_attachment_file_size( + 4.kilobytes, + message: "must be greater than 5 KB" + ) + end + + context "given a size range" do + before do + build_validator in: (5.kilobytes..10.kilobytes) + end + + should_not_allow_attachment_file_size( + 11.kilobytes, + message: "must be in between 5 KB and 10 KB" + ) + + should_not_allow_attachment_file_size( + 4.kilobytes, + message: "must be in between 5 KB and 10 KB" + ) + end + end + + context "using the helper" do + before do + Dummy.validates_attachment_size :avatar, in: (5.kilobytes..10.kilobytes) + end + + it "adds the validator to the class" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_size } + end + end + + context "given options" do + it "raises argument error if no required argument was given" do + assert_raises(ArgumentError) do + build_validator message: "Some message" + end + end + + (Paperclip::Validators::AttachmentSizeValidator::AVAILABLE_CHECKS).each do |argument| + it "does not raise arguemnt error if #{argument} was given" do + build_validator argument => 5.kilobytes + end + end + + it "does not raise argument error if :in was given" do + build_validator in: (5.kilobytes..10.kilobytes) + end + end +end diff --git a/spec/paperclip/validators/media_type_spoof_detection_validator_spec.rb b/spec/paperclip/validators/media_type_spoof_detection_validator_spec.rb new file mode 100644 index 000000000..384a10680 --- /dev/null +++ b/spec/paperclip/validators/media_type_spoof_detection_validator_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Paperclip::Validators::MediaTypeSpoofDetectionValidator do + before do + rebuild_model + @dummy = Dummy.new + end + + def build_validator(options = {}) + @validator = Paperclip::Validators::MediaTypeSpoofDetectionValidator.new(options.merge( + attributes: :avatar + )) + end + + it "is on the attachment without being explicitly added" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :media_type_spoof_detection } + end + + it "is not on the attachment when explicitly rejected" do + rebuild_model validate_media_type: false + assert Dummy.validators_on(:avatar).none?{ |validator| validator.kind == :media_type_spoof_detection } + end + + it "returns default error message for spoofed media type" do + build_validator + file = File.new(fixture_file("5k.png"), "rb") + @dummy.avatar.assign(file) + + detector = mock("detector", :spoofed? => true) + Paperclip::MediaTypeSpoofDetector.stubs(:using).returns(detector) + @validator.validate(@dummy) + + assert_equal I18n.t("errors.messages.spoofed_media_type"), @dummy.errors[:avatar].first + end + + it "runs when attachment is dirty" do + build_validator + file = File.new(fixture_file("5k.png"), "rb") + @dummy.avatar.assign(file) + Paperclip::MediaTypeSpoofDetector.stubs(:using).returns(stub(:spoofed? => false)) + + @dummy.valid? + + assert_received(Paperclip::MediaTypeSpoofDetector, :using){|e| e.once } + end + + it "does not run when attachment is not dirty" do + Paperclip::MediaTypeSpoofDetector.stubs(:using).never + @dummy.valid? + assert_received(Paperclip::MediaTypeSpoofDetector, :using){|e| e.never } + end +end diff --git a/spec/paperclip/validators_spec.rb b/spec/paperclip/validators_spec.rb new file mode 100644 index 000000000..189e3a735 --- /dev/null +++ b/spec/paperclip/validators_spec.rb @@ -0,0 +1,164 @@ +require 'spec_helper' + +describe Paperclip::Validators do + context "using the helper" do + before do + rebuild_class + Dummy.validates_attachment :avatar, presence: true, content_type: { content_type: "image/jpeg" }, size: { in: 0..10240 } + end + + it "adds the attachment_presence validator to the class" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_presence } + end + + it "adds the attachment_content_type validator to the class" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_content_type } + end + + it "adds the attachment_size validator to the class" do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_size } + end + + it 'prevents you from attaching a file that violates that validation' do + Dummy.class_eval{ validate(:name) { raise "DO NOT RUN THIS" } } + dummy = Dummy.new(avatar: File.new(fixture_file("12k.png"))) + expect(dummy.errors.keys).to match_array [:avatar_content_type, :avatar, :avatar_file_size] + assert_raises(RuntimeError){ dummy.valid? } + end + end + + context 'using the helper with array of validations' do + before do + rebuild_class + Dummy.validates_attachment :avatar, file_type_ignorance: true, file_name: [ + { matches: /\A.*\.jpe?g\z/i, message: :invalid_extension }, + { matches: /\A.{,8}\..+\z/i, message: [:too_long, count: 8] }, + ] + end + + it 'adds the attachment_file_name validator to the class' do + assert Dummy.validators_on(:avatar).any?{ |validator| validator.kind == :attachment_file_name } + end + + it 'adds the attachment_file_name validator with two validations' do + assert_equal 2, Dummy.validators_on(:avatar).select{ |validator| validator.kind == :attachment_file_name }.size + end + + it 'prevents you from attaching a file that violates all of these validations' do + Dummy.class_eval{ validate(:name) { raise 'DO NOT RUN THIS' } } + dummy = Dummy.new(avatar: File.new(fixture_file('spaced file.png'))) + expect(dummy.errors.keys).to match_array [:avatar, :avatar_file_name] + assert_raises(RuntimeError){ dummy.valid? } + end + + it 'prevents you from attaching a file that violates only first of these validations' do + Dummy.class_eval{ validate(:name) { raise 'DO NOT RUN THIS' } } + dummy = Dummy.new(avatar: File.new(fixture_file('5k.png'))) + expect(dummy.errors.keys).to match_array [:avatar, :avatar_file_name] + assert_raises(RuntimeError){ dummy.valid? } + end + + it 'prevents you from attaching a file that violates only second of these validations' do + Dummy.class_eval{ validate(:name) { raise 'DO NOT RUN THIS' } } + dummy = Dummy.new(avatar: File.new(fixture_file('spaced file.jpg'))) + expect(dummy.errors.keys).to match_array [:avatar, :avatar_file_name] + assert_raises(RuntimeError){ dummy.valid? } + end + + it 'allows you to attach a file that does not violate these validations' do + dummy = Dummy.new(avatar: File.new(fixture_file('rotated.jpg'))) + expect(dummy.errors.full_messages).to be_empty + assert dummy.valid? + end + end + + context "using the helper with a conditional" do + before do + rebuild_class + Dummy.validates_attachment :avatar, presence: true, + content_type: { content_type: "image/jpeg" }, + size: { in: 0..10240 }, + if: :title_present? + end + + it "validates the attachment if title is present" do + Dummy.class_eval do + def title_present? + true + end + end + dummy = Dummy.new(avatar: File.new(fixture_file("12k.png"))) + expect(dummy.errors.keys).to match_array [:avatar_content_type, :avatar, :avatar_file_size] + end + + it "does not validate attachment if title is not present" do + Dummy.class_eval do + def title_present? + false + end + end + dummy = Dummy.new(avatar: File.new(fixture_file("12k.png"))) + assert_equal [], dummy.errors.keys + end + end + + context 'with no other validations on the Dummy#avatar attachment' do + before do + reset_class("Dummy") + Dummy.has_attached_file :avatar + Paperclip.reset_duplicate_clash_check! + end + + it 'raises an error when no content_type validation exists' do + assert_raises(Paperclip::Errors::MissingRequiredValidatorError) do + Dummy.new(avatar: File.new(fixture_file("12k.png"))) + end + end + + it 'does not raise an error when a content_type validation exists' do + Dummy.validates_attachment :avatar, content_type: { content_type: "image/jpeg" } + + assert_nothing_raised do + Dummy.new(avatar: File.new(fixture_file("12k.png"))) + end + end + + it 'does not raise an error when a content_type validation exists using validates_with' do + Dummy.validates_with Paperclip::Validators::AttachmentContentTypeValidator, attributes: :attachment, content_type: 'images/jpeg' + + assert_nothing_raised do + Dummy.new(avatar: File.new(fixture_file("12k.png"))) + end + end + + it 'does not raise an error when an inherited validator is used' do + class MyValidator < Paperclip::Validators::AttachmentContentTypeValidator + def initialize(options) + options[:content_type] = "images/jpeg" unless options.has_key?(:content_type) + super + end + end + Dummy.validates_with MyValidator, attributes: :attachment + + assert_nothing_raised do + Dummy.new(avatar: File.new(fixture_file("12k.png"))) + end + end + + it 'does not raise an error when a file_name validation exists' do + Dummy.validates_attachment :avatar, file_name: { matches: /png$/ } + + assert_nothing_raised do + Dummy.new(avatar: File.new(fixture_file("12k.png"))) + end + end + + it 'does not raise an error when a the validation has been explicitly rejected' do + Dummy.validates_attachment :avatar, file_type_ignorance: true + + assert_nothing_raised do + Dummy.new(avatar: File.new(fixture_file("12k.png"))) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..9cbbc44ea --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,46 @@ +require 'rubygems' +require 'rspec' +require 'active_record' +require 'active_record/version' +require 'active_support' +require 'active_support/core_ext' +require 'mocha/api' +require 'bourne' +require 'ostruct' +require 'pathname' +require 'activerecord-import' + +ROOT = Pathname(File.expand_path(File.join(File.dirname(__FILE__), '..'))) + +puts "Testing against version #{ActiveRecord::VERSION::STRING}" + +$LOAD_PATH << File.join(ROOT, 'lib') +$LOAD_PATH << File.join(ROOT, 'lib', 'paperclip') +require File.join(ROOT, 'lib', 'paperclip.rb') + +FIXTURES_DIR = File.join(File.dirname(__FILE__), "fixtures") +config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) +ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log") +ActiveRecord::Base.establish_connection(config['test']) +if ActiveRecord::VERSION::STRING >= "4.2" && + ActiveRecord::VERSION::STRING < "5.0" + ActiveRecord::Base.raise_in_transactional_callbacks = true +end +Paperclip.options[:logger] = ActiveRecord::Base.logger + +Dir[File.join(ROOT, 'spec', 'support', '**', '*.rb')].each{|f| require f } + +Rails = FakeRails.new('test', Pathname.new(ROOT).join('tmp')) +ActiveSupport::Deprecation.silenced = true + +RSpec.configure do |config| + config.include Assertions + config.include ModelReconstruction + config.include TestData + config.include Reporting + config.extend VersionHelper + config.mock_framework = :mocha + config.before(:all) do + rebuild_model + end +end diff --git a/spec/support/assertions.rb b/spec/support/assertions.rb new file mode 100644 index 000000000..09cbd1aaf --- /dev/null +++ b/spec/support/assertions.rb @@ -0,0 +1,82 @@ +module Assertions + def assert(truthy, message = nil) + expect(!!truthy).to(eq(true), message) + end + + def assert_equal(expected, actual, message = nil) + expect(actual).to(eq(expected), message) + end + + def assert_not_equal(expected, actual, message = nil) + expect(actual).to_not(eq(expected), message) + end + + def assert_raises(exception_class, message = nil, &block) + expect(&block).to raise_error(exception_class, message) + end + + def assert_nothing_raised(&block) + expect(&block).to_not raise_error + end + + def assert_nil(thing) + expect(thing).to be_nil + end + + def assert_contains(haystack, needle) + expect(haystack).to include(needle) + end + + def assert_match(pattern, value) + expect(value).to match(pattern) + end + + def assert_no_match(pattern, value) + expect(value).to_not match(pattern) + end + + def assert_file_exists(path_to_file) + expect(path_to_file).to exist + end + + def assert_file_not_exists(path_to_file) + expect(path_to_file).to_not exist + end + + def assert_empty(object) + expect(object).to be_empty + end + + def assert_success_response(url) + url = "http:#{url}" unless url =~ /http/ + Net::HTTP.get_response(URI.parse(url)) do |response| + assert_equal "200", + response.code, + "Expected HTTP response code 200, got #{response.code}" + end + end + + def assert_not_found_response(url) + url = "http:#{url}" unless url =~ /http/ + Net::HTTP.get_response(URI.parse(url)) do |response| + assert_equal "404", response.code, + "Expected HTTP response code 404, got #{response.code}" + end + end + + def assert_forbidden_response(url) + url = "http:#{url}" unless url =~ /http/ + Net::HTTP.get_response(URI.parse(url)) do |response| + assert_equal "403", response.code, + "Expected HTTP response code 403, got #{response.code}" + end + end + + def assert_frame_dimensions(range, frames) + frames.each_with_index do |frame, frame_index| + frame.split('x').each_with_index do |dimension, dimension_index | + assert range.include?(dimension.to_i), "Frame #{frame_index}[#{dimension_index}] should have been within #{range.inspect}, but was #{dimension}" + end + end + end +end diff --git a/spec/support/fake_model.rb b/spec/support/fake_model.rb new file mode 100644 index 000000000..812008f6d --- /dev/null +++ b/spec/support/fake_model.rb @@ -0,0 +1,25 @@ +class FakeModel + attr_accessor( + :avatar_file_name, + :avatar_file_size, + :avatar_updated_at, + :avatar_content_type, + :avatar_fingerprint, + :id + ) + + def errors + @errors ||= [] + end + + def run_paperclip_callbacks name, *args + end + + def valid? + errors.empty? + end + + def new_record? + false + end +end diff --git a/spec/support/fake_rails.rb b/spec/support/fake_rails.rb new file mode 100644 index 000000000..038d0a18a --- /dev/null +++ b/spec/support/fake_rails.rb @@ -0,0 +1,12 @@ +class FakeRails + def initialize(env, root) + @env = env + @root = root + end + + attr_accessor :env, :root + + def const_defined?(const) + false + end +end diff --git a/test/fixtures/12k.png b/spec/support/fixtures/12k.png similarity index 100% rename from test/fixtures/12k.png rename to spec/support/fixtures/12k.png diff --git a/test/fixtures/50x50.png b/spec/support/fixtures/50x50.png similarity index 100% rename from test/fixtures/50x50.png rename to spec/support/fixtures/50x50.png diff --git a/test/fixtures/5k.png b/spec/support/fixtures/5k.png similarity index 100% rename from test/fixtures/5k.png rename to spec/support/fixtures/5k.png diff --git a/test/fixtures/animated.gif b/spec/support/fixtures/animated similarity index 100% rename from test/fixtures/animated.gif rename to spec/support/fixtures/animated diff --git a/spec/support/fixtures/animated.gif b/spec/support/fixtures/animated.gif new file mode 100644 index 000000000..7eca29038 Binary files /dev/null and b/spec/support/fixtures/animated.gif differ diff --git a/spec/support/fixtures/animated.unknown b/spec/support/fixtures/animated.unknown new file mode 100644 index 000000000..7eca29038 Binary files /dev/null and b/spec/support/fixtures/animated.unknown differ diff --git a/test/fixtures/bad.png b/spec/support/fixtures/bad.png similarity index 100% rename from test/fixtures/bad.png rename to spec/support/fixtures/bad.png diff --git a/spec/support/fixtures/empty.html b/spec/support/fixtures/empty.html new file mode 100644 index 000000000..18ecdcb79 --- /dev/null +++ b/spec/support/fixtures/empty.html @@ -0,0 +1 @@ + diff --git a/spec/support/fixtures/empty.xlsx b/spec/support/fixtures/empty.xlsx new file mode 100644 index 000000000..4792356ec Binary files /dev/null and b/spec/support/fixtures/empty.xlsx differ diff --git a/test/fixtures/fog.yml b/spec/support/fixtures/fog.yml similarity index 100% rename from test/fixtures/fog.yml rename to spec/support/fixtures/fog.yml diff --git a/spec/support/fixtures/rotated.jpg b/spec/support/fixtures/rotated.jpg new file mode 100644 index 000000000..8d634848f Binary files /dev/null and b/spec/support/fixtures/rotated.jpg differ diff --git a/test/fixtures/s3.yml b/spec/support/fixtures/s3.yml similarity index 100% rename from test/fixtures/s3.yml rename to spec/support/fixtures/s3.yml diff --git a/spec/support/fixtures/spaced file.jpg b/spec/support/fixtures/spaced file.jpg new file mode 100644 index 000000000..8d634848f Binary files /dev/null and b/spec/support/fixtures/spaced file.jpg differ diff --git a/test/fixtures/spaced file.png b/spec/support/fixtures/spaced file.png similarity index 100% rename from test/fixtures/spaced file.png rename to spec/support/fixtures/spaced file.png diff --git a/test/fixtures/text.txt b/spec/support/fixtures/text.txt similarity index 100% rename from test/fixtures/text.txt rename to spec/support/fixtures/text.txt diff --git a/test/fixtures/twopage.pdf b/spec/support/fixtures/twopage.pdf similarity index 100% rename from test/fixtures/twopage.pdf rename to spec/support/fixtures/twopage.pdf diff --git a/test/fixtures/uppercase.PNG b/spec/support/fixtures/uppercase.PNG similarity index 100% rename from test/fixtures/uppercase.PNG rename to spec/support/fixtures/uppercase.PNG diff --git a/spec/support/matchers/accept.rb b/spec/support/matchers/accept.rb new file mode 100644 index 000000000..0530daf7f --- /dev/null +++ b/spec/support/matchers/accept.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :accept do |expected| + match do |actual| + actual.matches?(expected) + end +end diff --git a/spec/support/matchers/exist.rb b/spec/support/matchers/exist.rb new file mode 100644 index 000000000..1060f75c0 --- /dev/null +++ b/spec/support/matchers/exist.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :exist do |expected| + match do |actual| + File.exist?(actual) + end +end diff --git a/spec/support/matchers/have_column.rb b/spec/support/matchers/have_column.rb new file mode 100644 index 000000000..4d1c234a0 --- /dev/null +++ b/spec/support/matchers/have_column.rb @@ -0,0 +1,23 @@ +RSpec::Matchers.define :have_column do |column_name| + chain :with_default do |default| + @default = default + end + + match do |columns| + column = columns.detect{|column| column.name == column_name } + column && column.default.to_s == @default.to_s + end + + failure_message_method = + if RSpec::Version::STRING.to_i >= 3 + :failure_message + else + :failure_message_for_should + end + + send(failure_message_method) do |columns| + "expected to find '#{column_name}', " + + "default '#{@default}' " + + "in #{columns.map { |column| [column.name, column.default] }}" + end +end diff --git a/test/support/mock_attachment.rb b/spec/support/mock_attachment.rb similarity index 91% rename from test/support/mock_attachment.rb rename to spec/support/mock_attachment.rb index 873df66ef..3b924375d 100644 --- a/test/support/mock_attachment.rb +++ b/spec/support/mock_attachment.rb @@ -1,7 +1,9 @@ class MockAttachment attr_accessor :updated_at, :original_filename + attr_reader :options def initialize(options = {}) + @options = options @model = options[:model] @responds_to_updated_at = options[:responds_to_updated_at] @updated_at = options[:updated_at] diff --git a/test/support/mock_interpolator.rb b/spec/support/mock_interpolator.rb similarity index 100% rename from test/support/mock_interpolator.rb rename to spec/support/mock_interpolator.rb diff --git a/test/support/mock_url_generator_builder.rb b/spec/support/mock_url_generator_builder.rb similarity index 86% rename from test/support/mock_url_generator_builder.rb rename to spec/support/mock_url_generator_builder.rb index 2afd7a49f..afdcdf6cb 100644 --- a/test/support/mock_url_generator_builder.rb +++ b/spec/support/mock_url_generator_builder.rb @@ -2,9 +2,9 @@ class MockUrlGeneratorBuilder def initializer end - def new(attachment, attachment_options) + def new(attachment) @attachment = attachment - @attachment_options = attachment_options + @attachment_options = @attachment.options self end diff --git a/spec/support/model_reconstruction.rb b/spec/support/model_reconstruction.rb new file mode 100644 index 000000000..880d367ce --- /dev/null +++ b/spec/support/model_reconstruction.rb @@ -0,0 +1,68 @@ +module ModelReconstruction + def reset_class class_name + ActiveRecord::Base.send(:include, Paperclip::Glue) + Object.send(:remove_const, class_name) rescue nil + klass = Object.const_set(class_name, Class.new(ActiveRecord::Base)) + + klass.class_eval do + include Paperclip::Glue + end + + klass.reset_column_information + klass.connection_pool.clear_table_cache!(klass.table_name) if klass.connection_pool.respond_to?(:clear_table_cache!) + + if klass.connection.respond_to?(:schema_cache) + if ActiveRecord::VERSION::STRING >= "5.0" + klass.connection.schema_cache.clear_data_source_cache!(klass.table_name) + else + klass.connection.schema_cache.clear_table_cache!(klass.table_name) + end + end + + klass + end + + def reset_table table_name, &block + block ||= lambda { |table| true } + ActiveRecord::Base.connection.create_table :dummies, {force: true}, &block + end + + def modify_table &block + ActiveRecord::Base.connection.change_table :dummies, &block + end + + def rebuild_model options = {} + ActiveRecord::Base.connection.create_table :dummies, force: true do |table| + table.column :title, :string + table.column :other, :string + table.column :avatar_file_name, :string + table.column :avatar_content_type, :string + table.column :avatar_file_size, :integer + table.column :avatar_updated_at, :datetime + table.column :avatar_fingerprint, :string + end + rebuild_class options + end + + def rebuild_class options = {} + reset_class("Dummy").tap do |klass| + klass.has_attached_file :avatar, options + klass.do_not_validate_attachment_file_type :avatar + Paperclip.reset_duplicate_clash_check! + end + end + + def rebuild_meta_class_of obj, options = {} + meta_class_of(obj).tap do |metaklass| + metaklass.has_attached_file :avatar, options + metaklass.do_not_validate_attachment_file_type :avatar + Paperclip.reset_duplicate_clash_check! + end + end + + def meta_class_of(obj) + class << obj + self + end + end +end diff --git a/spec/support/reporting.rb b/spec/support/reporting.rb new file mode 100644 index 000000000..ed970d2bb --- /dev/null +++ b/spec/support/reporting.rb @@ -0,0 +1,11 @@ +module Reporting + def silence_stream(stream) + old_stream = stream.dup + stream.reopen(RbConfig::CONFIG['host_os'] =~ /mswin|mingw/ ? 'NUL:' : '/dev/null') + stream.sync = true + yield + ensure + stream.reopen(old_stream) + old_stream.close + end +end diff --git a/spec/support/test_data.rb b/spec/support/test_data.rb new file mode 100644 index 000000000..9a8951398 --- /dev/null +++ b/spec/support/test_data.rb @@ -0,0 +1,13 @@ +module TestData + def attachment(options={}) + Paperclip::Attachment.new(:avatar, FakeModel.new, options) + end + + def stringy_file + StringIO.new('.\n') + end + + def fixture_file(filename) + File.join(File.dirname(__FILE__), 'fixtures', filename) + end +end diff --git a/spec/support/version_helper.rb b/spec/support/version_helper.rb new file mode 100644 index 000000000..116efec77 --- /dev/null +++ b/spec/support/version_helper.rb @@ -0,0 +1,9 @@ +module VersionHelper + def active_support_version + ActiveSupport::VERSION::STRING + end + + def ruby_version + RUBY_VERSION + end +end diff --git a/test/attachment_options_test.rb b/test/attachment_options_test.rb deleted file mode 100644 index 1ac8ea757..000000000 --- a/test/attachment_options_test.rb +++ /dev/null @@ -1,40 +0,0 @@ -require './test/helper' - -class AttachmentOptionsTest < Test::Unit::TestCase - should "be a Hash" do - assert_kind_of Hash, Paperclip::AttachmentOptions.new({}) - end - - should "add a default empty validations" do - options = {:arbi => :trary} - expected = {:validations => []}.merge(options) - actual = Paperclip::AttachmentOptions.new(options).to_hash - assert_equal expected, actual - end - - should "not override validations if passed to initializer" do - options = {:validations => "something"} - attachment_options = Paperclip::AttachmentOptions.new(options) - assert_equal "something", attachment_options[:validations] - end - - should "respond to []" do - assert Paperclip::AttachmentOptions.new({}).respond_to?(:[]) - end - - should "deliver the specified options through []" do - intended_options = {:specific_key => "specific value"} - attachment_options = Paperclip::AttachmentOptions.new(intended_options) - assert_equal "specific value", attachment_options[:specific_key] - end - - should "respond to []=" do - assert Paperclip::AttachmentOptions.new({}).respond_to?(:[]=) - end - - should "remember options set with []=" do - attachment_options = Paperclip::AttachmentOptions.new({}) - attachment_options[:foo] = "bar" - assert_equal "bar", attachment_options[:foo] - end -end diff --git a/test/attachment_test.rb b/test/attachment_test.rb deleted file mode 100644 index cf3243126..000000000 --- a/test/attachment_test.rb +++ /dev/null @@ -1,1189 +0,0 @@ -# encoding: utf-8 -require './test/helper' -require 'paperclip/attachment' - -class Dummy; end - -class AttachmentTest < Test::Unit::TestCase - - should "process :original style first" do - file = File.new(File.join(File.dirname(__FILE__), "fixtures", "50x50.png"), 'rb') - rebuild_class :styles => { :small => '100x>', :original => '42x42#' } - dummy = Dummy.new - dummy.avatar = file - dummy.save - - # :small avatar should be 42px wide (processed original), not 50px (preprocessed original) - assert_equal `identify -format "%w" "#{dummy.avatar.path(:small)}"`.strip, "42" - - file.close - end - - should "handle a boolean second argument to #url" do - mock_url_generator_builder = MockUrlGeneratorBuilder.new - attachment = Paperclip::Attachment.new(:name, :instance, :url_generator => mock_url_generator_builder) - - attachment.url(:style_name, true) - assert mock_url_generator_builder.has_generated_url_with_options?(:timestamp => true, :escape => true) - - attachment.url(:style_name, false) - assert mock_url_generator_builder.has_generated_url_with_options?(:timestamp => false, :escape => true) - end - - should "pass the style and options through to the URL generator on #url" do - mock_url_generator_builder = MockUrlGeneratorBuilder.new - attachment = Paperclip::Attachment.new(:name, :instance, :url_generator => mock_url_generator_builder) - - attachment.url(:style_name, :options => :values) - assert mock_url_generator_builder.has_generated_url_with_options?(:options => :values) - end - - should "pass default options through when #url is given one argument" do - mock_url_generator_builder = MockUrlGeneratorBuilder.new - attachment = Paperclip::Attachment.new(:name, - :instance, - :url_generator => mock_url_generator_builder, - :use_timestamp => true) - - attachment.url(:style_name) - assert mock_url_generator_builder.has_generated_url_with_options?(:escape => true, :timestamp => true) - end - - should "pass default style and options through when #url is given no arguments" do - mock_url_generator_builder = MockUrlGeneratorBuilder.new - attachment = Paperclip::Attachment.new(:name, - :instance, - :default_style => 'default style', - :url_generator => mock_url_generator_builder, - :use_timestamp => true) - - attachment.url - assert mock_url_generator_builder.has_generated_url_with_options?(:escape => true, :timestamp => true) - assert mock_url_generator_builder.has_generated_url_with_style_name?('default style') - end - - should "pass the option :timestamp => true if :use_timestamp is true and :timestamp is not passed" do - mock_url_generator_builder = MockUrlGeneratorBuilder.new - attachment = Paperclip::Attachment.new(:name, - :instance, - :url_generator => mock_url_generator_builder, - :use_timestamp => true) - - attachment.url(:style_name) - assert mock_url_generator_builder.has_generated_url_with_options?(:escape => true, :timestamp => true) - end - - should "pass the option :timestamp => false if :use_timestamp is false and :timestamp is not passed" do - mock_url_generator_builder = MockUrlGeneratorBuilder.new - attachment = Paperclip::Attachment.new(:name, - :instance, - :url_generator => mock_url_generator_builder, - :use_timestamp => false) - - attachment.url(:style_name) - assert mock_url_generator_builder.has_generated_url_with_options?(:escape => true, :timestamp => false) - end - - should "not change the :timestamp if :timestamp is passed" do - mock_url_generator_builder = MockUrlGeneratorBuilder.new - attachment = Paperclip::Attachment.new(:name, - :instance, - :url_generator => mock_url_generator_builder, - :use_timestamp => false) - - attachment.url(:style_name, :timestamp => true) - assert mock_url_generator_builder.has_generated_url_with_options?(:escape => true, :timestamp => true) - end - - should "return the path based on the url by default" do - @attachment = attachment :url => "/:class/:id/:basename" - @model = @attachment.instance - @model.id = 1234 - @model.avatar_file_name = "fake.jpg" - assert_equal "#{Rails.root}/public/fake_models/1234/fake", @attachment.path - end - - context "Attachment default_options" do - setup do - rebuild_model - @old_default_options = Paperclip::Attachment.default_options.dup - @new_default_options = @old_default_options.merge({ - :path => "argle/bargle", - :url => "fooferon", - :default_url => "not here.png" - }) - end - - teardown do - Paperclip::Attachment.default_options.merge! @old_default_options - end - - should "be overrideable" do - Paperclip::Attachment.default_options.merge!(@new_default_options) - @new_default_options.keys.each do |key| - assert_equal @new_default_options[key], - Paperclip::Attachment.default_options[key] - end - end - - context "without an Attachment" do - setup do - @dummy = Dummy.new - end - - should "return false when asked exists?" do - assert !@dummy.avatar.exists? - end - end - - context "on an Attachment" do - setup do - @dummy = Dummy.new - @attachment = @dummy.avatar - end - - Paperclip::Attachment.default_options.keys.each do |key| - should "be the default_options for #{key}" do - assert_equal @old_default_options[key], - @attachment.instance_variable_get("@options")[key], - key - end - end - - context "when redefined" do - setup do - Paperclip::Attachment.default_options.merge!(@new_default_options) - @dummy = Dummy.new - @attachment = @dummy.avatar - end - - Paperclip::Attachment.default_options.keys.each do |key| - should "be the new default_options for #{key}" do - assert_equal @new_default_options[key], - @attachment.instance_variable_get("@options")[key], - key - end - end - end - end - end - - context "An attachment with similarly named interpolations" do - setup do - rebuild_model :path => ":id.omg/:id-bbq/:idwhat/:id_partition.wtf" - @dummy = Dummy.new - @dummy.stubs(:id).returns(1024) - @file = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - @dummy.avatar = @file - end - - teardown { @file.close } - - should "make sure that they are interpolated correctly" do - assert_equal "1024.omg/1024-bbq/1024what/000/001/024.wtf", @dummy.avatar.path - end - end - - context "An attachment with :timestamp interpolations" do - setup do - @file = StringIO.new("...") - @zone = 'UTC' - Time.stubs(:zone).returns(@zone) - @zone_default = 'Eastern Time (US & Canada)' - Time.stubs(:zone_default).returns(@zone_default) - end - - context "using default time zone" do - setup do - rebuild_model :path => ":timestamp", :use_default_time_zone => true - @dummy = Dummy.new - @dummy.avatar = @file - end - - should "return a time in the default zone" do - assert_equal @dummy.avatar_updated_at.in_time_zone(@zone_default).to_s, @dummy.avatar.path - end - end - - context "using per-thread time zone" do - setup do - rebuild_model :path => ":timestamp", :use_default_time_zone => false - @dummy = Dummy.new - @dummy.avatar = @file - end - - should "return a time in the per-thread zone" do - assert_equal @dummy.avatar_updated_at.in_time_zone(@zone).to_s, @dummy.avatar.path - end - end - end - - context "An attachment with :hash interpolations" do - setup do - @file = StringIO.new("...") - end - - should "raise if no secret is provided" do - @attachment = attachment :path => ":hash" - @attachment.assign @file - - assert_raise ArgumentError do - @attachment.path - end - end - - context "when secret is set" do - setup do - @attachment = attachment :path => ":hash", :hash_secret => "w00t" - @attachment.stubs(:instance_read).with(:updated_at).returns(Time.at(1234567890)) - @attachment.stubs(:instance_read).with(:file_name).returns("bla.txt") - @attachment.instance.id = 1234 - @attachment.assign @file - end - - should "interpolate the hash data" do - @attachment.expects(:interpolate).with(@attachment.options[:hash_data],anything).returns("interpolated_stuff") - @attachment.hash_key - end - - should "result in the correct interpolation" do - assert_equal "fake_models/avatars/1234/original/1234567890", @attachment.send(:interpolate,@attachment.options[:hash_data]) - end - - should "result in a correct hash" do - assert_equal "d22b617d1bf10016aa7d046d16427ae203f39fce", @attachment.path - end - - should "generate a hash digest with the correct style" do - OpenSSL::HMAC.expects(:hexdigest).with(anything, anything, "fake_models/avatars/1234/medium/1234567890") - @attachment.path("medium") - end - end - end - - context "An attachment with a :rails_env interpolation" do - setup do - @rails_env = "blah" - @id = 1024 - rebuild_model :path => ":rails_env/:id.png" - @dummy = Dummy.new - @dummy.stubs(:id).returns(@id) - @file = StringIO.new(".") - @dummy.avatar = @file - Rails.stubs(:env).returns(@rails_env) - end - - should "return the proper path" do - assert_equal "#{@rails_env}/#{@id}.png", @dummy.avatar.path - end - end - - context "An attachment with a default style and an extension interpolation" do - setup do - @attachment = attachment :path => ":basename.:extension", - :styles => { :default => ["100x100", :png] }, - :default_style => :default - @file = StringIO.new("...") - @file.stubs(:original_filename).returns("file.jpg") - end - should "return the right extension for the path" do - @attachment.assign(@file) - assert_equal "file.png", @attachment.path - end - end - - context "An attachment with :convert_options" do - setup do - rebuild_model :styles => { - :thumb => "100x100", - :large => "400x400" - }, - :convert_options => { - :all => "-do_stuff", - :thumb => "-thumbnailize" - } - @dummy = Dummy.new - @dummy.avatar - end - - should "report the correct options when sent #extra_options_for(:thumb)" do - assert_equal "-thumbnailize -do_stuff", @dummy.avatar.send(:extra_options_for, :thumb), @dummy.avatar.convert_options.inspect - end - - should "report the correct options when sent #extra_options_for(:large)" do - assert_equal "-do_stuff", @dummy.avatar.send(:extra_options_for, :large) - end - end - - context "An attachment with :source_file_options" do - setup do - rebuild_model :styles => { - :thumb => "100x100", - :large => "400x400" - }, - :source_file_options => { - :all => "-density 400", - :thumb => "-depth 8" - } - @dummy = Dummy.new - @dummy.avatar - end - - should "report the correct options when sent #extra_source_file_options_for(:thumb)" do - assert_equal "-depth 8 -density 400", @dummy.avatar.send(:extra_source_file_options_for, :thumb), @dummy.avatar.source_file_options.inspect - end - - should "report the correct options when sent #extra_source_file_options_for(:large)" do - assert_equal "-density 400", @dummy.avatar.send(:extra_source_file_options_for, :large) - end - end - - context "An attachment with :only_process" do - setup do - rebuild_model :styles => { - :thumb => "100x100", - :large => "400x400" - }, - :only_process => [:thumb] - @file = StringIO.new("...") - @attachment = Dummy.new.avatar - end - - should "only process the provided style" do - @attachment.expects(:post_process).with(:thumb) - @attachment.expects(:post_process).with(:large).never - @attachment.assign(@file) - end - end - - context "An attachment with :convert_options that is a proc" do - setup do - rebuild_model :styles => { - :thumb => "100x100", - :large => "400x400" - }, - :convert_options => { - :all => lambda{|i| i.all }, - :thumb => lambda{|i| i.thumb } - } - Dummy.class_eval do - def all; "-all"; end - def thumb; "-thumb"; end - end - @dummy = Dummy.new - @dummy.avatar - end - - should "report the correct options when sent #extra_options_for(:thumb)" do - assert_equal "-thumb -all", @dummy.avatar.send(:extra_options_for, :thumb), @dummy.avatar.convert_options.inspect - end - - should "report the correct options when sent #extra_options_for(:large)" do - assert_equal "-all", @dummy.avatar.send(:extra_options_for, :large) - end - end - - context "An attachment with :path that is a proc" do - setup do - rebuild_model :path => lambda{ |attachment| "path/#{attachment.instance.other}.:extension" } - - @file = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - @dummyA = Dummy.new(:other => 'a') - @dummyA.avatar = @file - @dummyB = Dummy.new(:other => 'b') - @dummyB.avatar = @file - end - - teardown { @file.close } - - should "return correct path" do - assert_equal "path/a.png", @dummyA.avatar.path - assert_equal "path/b.png", @dummyB.avatar.path - end - end - - context "An attachment with :styles that is a proc" do - setup do - rebuild_model :styles => lambda{ |attachment| {:thumb => "50x50#", :large => "400x400"} } - - @attachment = Dummy.new.avatar - end - - should "have the correct geometry" do - assert_equal "50x50#", @attachment.styles[:thumb][:geometry] - end - end - - context "An attachment with conditional :styles that is a proc" do - setup do - rebuild_model :styles => lambda{ |attachment| attachment.instance.other == 'a' ? {:thumb => "50x50#"} : {:large => "400x400"} } - - @dummy = Dummy.new(:other => 'a') - end - - should "have the correct styles for the assigned instance values" do - assert_equal "50x50#", @dummy.avatar.styles[:thumb][:geometry] - assert_nil @dummy.avatar.styles[:large] - - @dummy.other = 'b' - - assert_equal "400x400", @dummy.avatar.styles[:large][:geometry] - assert_nil @dummy.avatar.styles[:thumb] - end - end - - geometry_specs = [ - [ lambda{|z| "50x50#" }, :png ], - lambda{|z| "50x50#" }, - { :geometry => lambda{|z| "50x50#" } } - ] - geometry_specs.each do |geometry_spec| - context "An attachment geometry like #{geometry_spec}" do - setup do - rebuild_model :styles => { :normal => geometry_spec } - @attachment = Dummy.new.avatar - end - - context "when assigned" do - setup do - @file = StringIO.new(".") - @attachment.assign(@file) - end - - should "have the correct geometry" do - assert_equal "50x50#", @attachment.styles[:normal][:geometry] - end - end - end - end - - context "An attachment with both 'normal' and hash-style styles" do - setup do - rebuild_model :styles => { - :normal => ["50x50#", :png], - :hash => { :geometry => "50x50#", :format => :png } - } - @dummy = Dummy.new - @attachment = @dummy.avatar - end - - [:processors, :whiny, :convert_options, :geometry, :format].each do |field| - should "have the same #{field} field" do - assert_equal @attachment.styles[:normal][field], @attachment.styles[:hash][field] - end - end - end - - context "An attachment with :processors that is a proc" do - setup do - class Paperclip::Test < Paperclip::Processor; end - @file = StringIO.new("...") - Paperclip::Test.stubs(:make).returns(@file) - - rebuild_model :styles => { :normal => '' }, :processors => lambda { |a| [ :test ] } - @attachment = Dummy.new.avatar - end - - context "when assigned" do - setup do - @attachment.assign(StringIO.new(".")) - end - - should "have the correct processors" do - assert_equal [ :test ], @attachment.styles[:normal][:processors] - end - end - end - - context "An attachment with erroring processor" do - setup do - rebuild_model :processor => [:thumbnail], :styles => { :small => '' }, :whiny_thumbnails => true - @dummy = Dummy.new - Paperclip::Thumbnail.expects(:make).raises(Paperclip::PaperclipError, "cannot be processed.") - @file = StringIO.new("...") - @file.stubs(:to_tempfile).returns(@file) - @dummy.avatar = @file - end - - should "correctly forward processing error message to the instance" do - @dummy.valid? - assert_contains @dummy.errors.full_messages, "Avatar cannot be processed." - end - end - - context "An attachment with multiple processors" do - setup do - class Paperclip::Test < Paperclip::Processor; end - @style_params = { :once => {:one => 1, :two => 2} } - rebuild_model :processors => [:thumbnail, :test], :styles => @style_params - @dummy = Dummy.new - @file = StringIO.new("...") - @file.stubs(:to_tempfile).returns(@file) - Paperclip::Test.stubs(:make).returns(@file) - Paperclip::Thumbnail.stubs(:make).returns(@file) - end - - context "when assigned" do - setup { @dummy.avatar = @file } - - before_should "call #make on all specified processors" do - Paperclip::Thumbnail.expects(:make).with(any_parameters).returns(@file) - Paperclip::Test.expects(:make).with(any_parameters).returns(@file) - end - - before_should "call #make with the right parameters passed as second argument" do - expected_params = @style_params[:once].merge({ - :processors => [:thumbnail, :test], - :whiny => true, - :convert_options => "", - :source_file_options => "" - }) - Paperclip::Thumbnail.expects(:make).with(anything, expected_params, anything).returns(@file) - end - - before_should "call #make with attachment passed as third argument" do - Paperclip::Test.expects(:make).with(anything, anything, @dummy.avatar).returns(@file) - end - end - end - - should "include the filesystem module when loading the filesystem storage" do - rebuild_model :storage => :filesystem - @dummy = Dummy.new - assert @dummy.avatar.is_a?(Paperclip::Storage::Filesystem) - end - - should "include the filesystem module even if capitalization is wrong" do - rebuild_model :storage => :FileSystem - @dummy = Dummy.new - assert @dummy.avatar.is_a?(Paperclip::Storage::Filesystem) - - rebuild_model :storage => :Filesystem - @dummy = Dummy.new - assert @dummy.avatar.is_a?(Paperclip::Storage::Filesystem) - end - - should "convert underscored storage name to camelcase" do - rebuild_model :storage => :not_here - @dummy = Dummy.new - exception = assert_raises(Paperclip::StorageMethodNotFound) do - @dummy.avatar - end - assert exception.message.include?("NotHere") - end - - should "raise an error if you try to include a storage module that doesn't exist" do - rebuild_model :storage => :not_here - @dummy = Dummy.new - assert_raises(Paperclip::StorageMethodNotFound) do - @dummy.avatar - end - end - - context "An attachment with styles but no processors defined" do - setup do - rebuild_model :processors => [], :styles => {:something => '1'} - @dummy = Dummy.new - @file = StringIO.new("...") - end - should "raise when assigned to" do - assert_raises(RuntimeError){ @dummy.avatar = @file } - end - end - - context "An attachment without styles and with no processors defined" do - setup do - rebuild_model :processors => [], :styles => {} - @dummy = Dummy.new - @file = StringIO.new("...") - end - should "not raise when assigned to" do - @dummy.avatar = @file - end - end - - context "Assigning an attachment with post_process hooks" do - setup do - rebuild_class :styles => { :something => "100x100#" } - Dummy.class_eval do - before_avatar_post_process :do_before_avatar - after_avatar_post_process :do_after_avatar - before_post_process :do_before_all - after_post_process :do_after_all - def do_before_avatar; end - def do_after_avatar; end - def do_before_all; end - def do_after_all; end - end - @file = StringIO.new(".") - @file.stubs(:to_tempfile).returns(@file) - @dummy = Dummy.new - Paperclip::Thumbnail.stubs(:make).returns(@file) - @attachment = @dummy.avatar - end - - should "call the defined callbacks when assigned" do - @dummy.expects(:do_before_avatar).with() - @dummy.expects(:do_after_avatar).with() - @dummy.expects(:do_before_all).with() - @dummy.expects(:do_after_all).with() - Paperclip::Thumbnail.expects(:make).returns(@file) - @dummy.avatar = @file - end - - should "not cancel the processing if a before_post_process returns nil" do - @dummy.expects(:do_before_avatar).with().returns(nil) - @dummy.expects(:do_after_avatar).with() - @dummy.expects(:do_before_all).with().returns(nil) - @dummy.expects(:do_after_all).with() - Paperclip::Thumbnail.expects(:make).returns(@file) - @dummy.avatar = @file - end - - should "cancel the processing if a before_post_process returns false" do - @dummy.expects(:do_before_avatar).never - @dummy.expects(:do_after_avatar).never - @dummy.expects(:do_before_all).with().returns(false) - @dummy.expects(:do_after_all) - Paperclip::Thumbnail.expects(:make).never - @dummy.avatar = @file - end - - should "cancel the processing if a before_avatar_post_process returns false" do - @dummy.expects(:do_before_avatar).with().returns(false) - @dummy.expects(:do_after_avatar) - @dummy.expects(:do_before_all).with().returns(true) - @dummy.expects(:do_after_all) - Paperclip::Thumbnail.expects(:make).never - @dummy.avatar = @file - end - end - - context "Assigning an attachment" do - setup do - rebuild_model :styles => { :something => "100x100#" } - @file = StringIO.new(".") - @file.stubs(:original_filename).returns("5k.png\n\n") - @file.stubs(:content_type).returns("image/png\n\n") - @file.stubs(:to_tempfile).returns(@file) - @dummy = Dummy.new - Paperclip::Thumbnail.expects(:make).returns(@file) - @attachment = @dummy.avatar - @dummy.avatar = @file - end - - should "strip whitespace from original_filename field" do - assert_equal "5k.png", @dummy.avatar.original_filename - end - - should "strip whitespace from content_type field" do - assert_equal "image/png", @dummy.avatar.instance.avatar_content_type - end - end - - context "Attachment with strange letters" do - setup do - rebuild_model - - @not_file = mock("not_file") - @tempfile = mock("tempfile") - @not_file.stubs(:nil?).returns(false) - @not_file.expects(:size).returns(10) - @tempfile.expects(:size).returns(10) - @not_file.expects(:original_filename).returns("sheep_say_bæ.png\r\n") - @not_file.expects(:content_type).returns("image/png\r\n") - - @dummy = Dummy.new - @attachment = @dummy.avatar - @attachment.expects(:valid_assignment?).with(@not_file).returns(true) - @attachment.expects(:queue_existing_for_delete) - @attachment.expects(:post_process) - @attachment.expects(:to_tempfile).returns(@tempfile) - @attachment.expects(:generate_fingerprint).with(@tempfile).returns("12345") - @attachment.expects(:generate_fingerprint).with(@not_file).returns("12345") - @dummy.avatar = @not_file - end - - should "not remove strange letters" do - assert_equal "sheep_say_bæ.png", @dummy.avatar.original_filename - end - end - - context "Attachment with reserved filename" do - setup do - rebuild_model - @file = StringIO.new(".") - end - - context "with default configuration" do - "&$+,/:;=?@<>[]{}|\^~%# ".split(//).each do |character| - context "with character #{character}" do - setup do - @file.stubs(:original_filename).returns("file#{character}name.png") - @dummy = Dummy.new - @dummy.avatar = @file - end - - should "convert special character into underscore" do - assert_equal "file_name.png", @dummy.avatar.original_filename - end - end - end - end - - context "with specified regexp replacement" do - setup do - @old_defaults = Paperclip::Attachment.default_options.dup - Paperclip::Attachment.default_options.merge! :restricted_characters => /o/ - - @file.stubs(:original_filename).returns("goood.png") - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown do - Paperclip::Attachment.default_options.merge! @old_defaults - end - - should "match and convert that character" do - assert_equal "g___d.png", @dummy.avatar.original_filename - end - end - end - - context "Attachment with uppercase extension and a default style" do - setup do - @old_defaults = Paperclip::Attachment.default_options.dup - Paperclip::Attachment.default_options.merge!({ - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - }) - FileUtils.rm_rf("tmp") - rebuild_model - @instance = Dummy.new - @instance.stubs(:id).returns 123 - - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "uppercase.PNG"), 'rb') - - styles = {:styles => { :large => ["400x400", :jpg], - :medium => ["100x100", :jpg], - :small => ["32x32#", :jpg]}, - :default_style => :small} - @attachment = Paperclip::Attachment.new(:avatar, - @instance, - styles) - now = Time.now - Time.stubs(:now).returns(now) - @attachment.assign(@file) - @attachment.save - end - - teardown do - @file.close - Paperclip::Attachment.default_options.merge!(@old_defaults) - end - - should "should have matching to_s and url methods" do - file = @attachment.to_file - assert file - assert_match @attachment.to_s, @attachment.url - assert_match @attachment.to_s(:small), @attachment.url(:small) - file.close - end - end - - context "An attachment" do - setup do - @old_defaults = Paperclip::Attachment.default_options.dup - Paperclip::Attachment.default_options.merge!({ - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - }) - FileUtils.rm_rf("tmp") - rebuild_model - @instance = Dummy.new - @instance.stubs(:id).returns 123 - @attachment = Paperclip::Attachment.new(:avatar, @instance) - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') - end - - teardown do - @file.close - Paperclip::Attachment.default_options.merge!(@old_defaults) - end - - should "raise if there are not the correct columns when you try to assign" do - @other_attachment = Paperclip::Attachment.new(:not_here, @instance) - assert_raises(Paperclip::PaperclipError) do - @other_attachment.assign(@file) - end - end - - should "return nil as path when no file assigned" do - assert @attachment.to_file.nil? - assert_equal nil, @attachment.path - assert_equal nil, @attachment.path(:blah) - end - - context "with a file assigned but not saved yet" do - should "clear out any attached files" do - @attachment.assign(@file) - assert !@attachment.queued_for_write.blank? - @attachment.clear - assert @attachment.queued_for_write.blank? - end - end - - context "with a file assigned in the database" do - setup do - @attachment.stubs(:instance_read).with(:file_name).returns("5k.png") - @attachment.stubs(:instance_read).with(:content_type).returns("image/png") - @attachment.stubs(:instance_read).with(:file_size).returns(12345) - dtnow = DateTime.now - @now = Time.now - Time.stubs(:now).returns(@now) - @attachment.stubs(:instance_read).with(:updated_at).returns(dtnow) - end - - should "return the proper path when filename has a single .'s" do - assert_equal File.expand_path("./test/../tmp/avatars/dummies/original/#{@instance.id}/5k.png"), File.expand_path(@attachment.path) - end - - should "return the proper path when filename has multiple .'s" do - @attachment.stubs(:instance_read).with(:file_name).returns("5k.old.png") - assert_equal File.expand_path("./test/../tmp/avatars/dummies/original/#{@instance.id}/5k.old.png"), File.expand_path(@attachment.path) - end - - context "when expecting three styles" do - setup do - styles = {:styles => { :large => ["400x400", :png], - :medium => ["100x100", :gif], - :small => ["32x32#", :jpg]}} - @attachment = Paperclip::Attachment.new(:avatar, - @instance, - styles) - end - - context "and assigned a file" do - setup do - now = Time.now - Time.stubs(:now).returns(now) - @attachment.assign(@file) - end - - should "be dirty" do - assert @attachment.dirty? - end - - should "set uploaded_file for access beyond the paperclip lifecycle" do - assert_equal @file, @attachment.uploaded_file - end - - context "and saved" do - setup do - @attachment.save - end - - should "commit the files to disk" do - [:large, :medium, :small].each do |style| - io = @attachment.to_file(style) - # p "in commit to disk test, io is #{io.inspect} and @instance.id is #{@instance.id}" - assert File.exists?(io.path) - assert ! io.is_a?(::Tempfile) - io.close - end - end - - should "save the files as the right formats and sizes" do - [[:large, 400, 61, "PNG"], - [:medium, 100, 15, "GIF"], - [:small, 32, 32, "JPEG"]].each do |style| - cmd = %Q[identify -format "%w %h %b %m" "#{@attachment.path(style.first)}"] - out = `#{cmd}` - width, height, size, format = out.split(" ") - assert_equal style[1].to_s, width.to_s - assert_equal style[2].to_s, height.to_s - assert_equal style[3].to_s, format.to_s - end - end - - should "still have its #file attribute not be nil" do - assert ! (file = @attachment.to_file).nil? - file.close - end - - context "and trying to delete" do - setup do - @existing_names = @attachment.styles.keys.collect do |style| - @attachment.path(style) - end - end - - should "delete the files after assigning nil" do - @attachment.expects(:instance_write).with(:file_name, nil) - @attachment.expects(:instance_write).with(:content_type, nil) - @attachment.expects(:instance_write).with(:file_size, nil) - @attachment.expects(:instance_write).with(:fingerprint, nil) - @attachment.expects(:instance_write).with(:updated_at, nil) - @attachment.assign nil - @attachment.save - @existing_names.each{|f| assert ! File.exists?(f) } - end - - should "delete the files when you call #clear and #save" do - @attachment.expects(:instance_write).with(:file_name, nil) - @attachment.expects(:instance_write).with(:content_type, nil) - @attachment.expects(:instance_write).with(:file_size, nil) - @attachment.expects(:instance_write).with(:fingerprint, nil) - @attachment.expects(:instance_write).with(:updated_at, nil) - @attachment.clear - @attachment.save - @existing_names.each{|f| assert ! File.exists?(f) } - end - - should "delete the files when you call #delete" do - @attachment.expects(:instance_write).with(:file_name, nil) - @attachment.expects(:instance_write).with(:content_type, nil) - @attachment.expects(:instance_write).with(:file_size, nil) - @attachment.expects(:instance_write).with(:fingerprint, nil) - @attachment.expects(:instance_write).with(:updated_at, nil) - @attachment.destroy - @existing_names.each{|f| assert ! File.exists?(f) } - end - - context "when keeping old files" do - setup do - @attachment.options[:keep_old_files] = true - end - - should "keep the files after assigning nil" do - @attachment.expects(:instance_write).with(:file_name, nil) - @attachment.expects(:instance_write).with(:content_type, nil) - @attachment.expects(:instance_write).with(:file_size, nil) - @attachment.expects(:instance_write).with(:fingerprint, nil) - @attachment.expects(:instance_write).with(:updated_at, nil) - @attachment.assign nil - @attachment.save - @existing_names.each{|f| assert File.exists?(f) } - end - - should "keep the files when you call #clear and #save" do - @attachment.expects(:instance_write).with(:file_name, nil) - @attachment.expects(:instance_write).with(:content_type, nil) - @attachment.expects(:instance_write).with(:file_size, nil) - @attachment.expects(:instance_write).with(:fingerprint, nil) - @attachment.expects(:instance_write).with(:updated_at, nil) - @attachment.clear - @attachment.save - @existing_names.each{|f| assert File.exists?(f) } - end - - should "keep the files when you call #delete" do - @attachment.expects(:instance_write).with(:file_name, nil) - @attachment.expects(:instance_write).with(:content_type, nil) - @attachment.expects(:instance_write).with(:file_size, nil) - @attachment.expects(:instance_write).with(:fingerprint, nil) - @attachment.expects(:instance_write).with(:updated_at, nil) - @attachment.destroy - @existing_names.each{|f| assert File.exists?(f) } - end - end - end - end - end - end - end - - context "when trying a nonexistant storage type" do - setup do - rebuild_model :storage => :not_here - end - - should "not be able to find the module" do - assert_raise(Paperclip::StorageMethodNotFound){ Dummy.new.avatar } - end - end - end - - context "An attachment with only a avatar_file_name column" do - setup do - ActiveRecord::Base.connection.create_table :dummies, :force => true do |table| - table.column :avatar_file_name, :string - end - rebuild_class - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') - end - - teardown { @file.close } - - should "not error when assigned an attachment" do - assert_nothing_raised { @dummy.avatar = @file } - end - - should "return the time when sent #avatar_updated_at" do - now = Time.now - Time.stubs(:now).returns(now) - @dummy.avatar = @file - assert_equal now.to_i, @dummy.avatar.updated_at.to_i - end - - should "return nil when reloaded and sent #avatar_updated_at" do - @dummy.save - @dummy.reload - assert_nil @dummy.avatar.updated_at - end - - should "return the right value when sent #avatar_file_size" do - @dummy.avatar = @file - assert_equal @file.size, @dummy.avatar.size - end - - context "and avatar_updated_at column" do - setup do - ActiveRecord::Base.connection.add_column :dummies, :avatar_updated_at, :timestamp - rebuild_class - @dummy = Dummy.new - end - - should "not error when assigned an attachment" do - assert_nothing_raised { @dummy.avatar = @file } - end - - should "return the right value when sent #avatar_updated_at" do - now = Time.now - Time.stubs(:now).returns(now) - @dummy.avatar = @file - assert_equal now.to_i, @dummy.avatar.updated_at - end - end - - should "not calculate fingerprint after save" do - @dummy.avatar = @file - @dummy.save - assert_nil @dummy.avatar.fingerprint - end - - should "not calculate fingerprint before saving" do - @dummy.avatar = @file - assert_nil @dummy.avatar.fingerprint - end - - context "and avatar_content_type column" do - setup do - ActiveRecord::Base.connection.add_column :dummies, :avatar_content_type, :string - rebuild_class - @dummy = Dummy.new - end - - should "not error when assigned an attachment" do - assert_nothing_raised { @dummy.avatar = @file } - end - - should "return the right value when sent #avatar_content_type" do - @dummy.avatar = @file - assert_equal "image/png", @dummy.avatar.content_type - end - end - - context "and avatar_file_size column" do - setup do - ActiveRecord::Base.connection.add_column :dummies, :avatar_file_size, :integer - rebuild_class - @dummy = Dummy.new - end - - should "not error when assigned an attachment" do - assert_nothing_raised { @dummy.avatar = @file } - end - - should "return the right value when sent #avatar_file_size" do - @dummy.avatar = @file - assert_equal @file.size, @dummy.avatar.size - end - - should "return the right value when saved, reloaded, and sent #avatar_file_size" do - @dummy.avatar = @file - @dummy.save - @dummy = Dummy.find(@dummy.id) - assert_equal @file.size, @dummy.avatar.size - end - end - - context "and avatar_fingerprint column" do - setup do - ActiveRecord::Base.connection.add_column :dummies, :avatar_fingerprint, :string - rebuild_class - @dummy = Dummy.new - end - - should "not error when assigned an attachment" do - assert_nothing_raised { @dummy.avatar = @file } - end - - should "return the right value when sent #avatar_fingerprint" do - @dummy.avatar = @file - assert_equal 'aec488126c3b33c08a10c3fa303acf27', @dummy.avatar_fingerprint - end - - should "return the right value when saved, reloaded, and sent #avatar_fingerprint" do - @dummy.avatar = @file - @dummy.save - @dummy = Dummy.find(@dummy.id) - assert_equal 'aec488126c3b33c08a10c3fa303acf27', @dummy.avatar_fingerprint - end - end - end - - context "an attachment with delete_file option set to false" do - setup do - rebuild_model :preserve_files => true - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') - @dummy.avatar = @file - @dummy.save! - @attachment = @dummy.avatar - @path = @attachment.path - end - - should "not delete the files from storage when attachment is destroyed" do - @attachment.destroy - assert File.exists?(@path) - end - - should "not delete the file when model is destroyed" do - @dummy.destroy - assert File.exists?(@path) - end - end - - context "An attached file" do - setup do - rebuild_model - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') - @dummy.avatar = @file - @dummy.save! - @attachment = @dummy.avatar - @path = @attachment.path - end - - should "not be deleted when the model fails to destroy" do - @dummy.stubs(:destroy).raises(Exception) - - assert_raise Exception do - @dummy.destroy - end - - assert File.exists?(@path), "#{@path} does not exist." - end - - should "be deleted when the model is destroyed" do - @dummy.destroy - assert ! File.exists?(@path), "#{@path} does not exist." - end - end - -end diff --git a/test/geometry_test.rb b/test/geometry_test.rb deleted file mode 100644 index 7063db9ab..000000000 --- a/test/geometry_test.rb +++ /dev/null @@ -1,206 +0,0 @@ -require './test/helper' - -class GeometryTest < Test::Unit::TestCase - context "Paperclip::Geometry" do - should "correctly report its given dimensions" do - assert @geo = Paperclip::Geometry.new(1024, 768) - assert_equal 1024, @geo.width - assert_equal 768, @geo.height - end - - should "set height to 0 if height dimension is missing" do - assert @geo = Paperclip::Geometry.new(1024) - assert_equal 1024, @geo.width - assert_equal 0, @geo.height - end - - should "set width to 0 if width dimension is missing" do - assert @geo = Paperclip::Geometry.new(nil, 768) - assert_equal 0, @geo.width - assert_equal 768, @geo.height - end - - should "be generated from a WxH-formatted string" do - assert @geo = Paperclip::Geometry.parse("800x600") - assert_equal 800, @geo.width - assert_equal 600, @geo.height - end - - should "be generated from a xH-formatted string" do - assert @geo = Paperclip::Geometry.parse("x600") - assert_equal 0, @geo.width - assert_equal 600, @geo.height - end - - should "be generated from a Wx-formatted string" do - assert @geo = Paperclip::Geometry.parse("800x") - assert_equal 800, @geo.width - assert_equal 0, @geo.height - end - - should "be generated from a W-formatted string" do - assert @geo = Paperclip::Geometry.parse("800") - assert_equal 800, @geo.width - assert_equal 0, @geo.height - end - - should "ensure the modifier is nil if not present" do - assert @geo = Paperclip::Geometry.parse("123x456") - assert_nil @geo.modifier - end - - should "treat x and X the same in geometries" do - @lower = Paperclip::Geometry.parse("123x456") - @upper = Paperclip::Geometry.parse("123X456") - assert_equal 123, @lower.width - assert_equal 123, @upper.width - assert_equal 456, @lower.height - assert_equal 456, @upper.height - end - - ['>', '<', '#', '@', '%', '^', '!', nil].each do |mod| - should "ensure the modifier #{mod.inspect} is preserved" do - assert @geo = Paperclip::Geometry.parse("123x456#{mod}") - assert_equal mod, @geo.modifier - assert_equal "123x456#{mod}", @geo.to_s - end - end - - ['>', '<', '#', '@', '%', '^', '!', nil].each do |mod| - should "ensure the modifier #{mod.inspect} is preserved with no height" do - assert @geo = Paperclip::Geometry.parse("123x#{mod}") - assert_equal mod, @geo.modifier - assert_equal "123#{mod}", @geo.to_s - end - end - - should "make sure the modifier gets passed during transformation_to" do - assert @src = Paperclip::Geometry.parse("123x456") - assert @dst = Paperclip::Geometry.parse("123x456>") - assert_equal ["123x456>", nil], @src.transformation_to(@dst) - end - - should "generate correct ImageMagick formatting string for W-formatted string" do - assert @geo = Paperclip::Geometry.parse("800") - assert_equal "800", @geo.to_s - end - - should "generate correct ImageMagick formatting string for Wx-formatted string" do - assert @geo = Paperclip::Geometry.parse("800x") - assert_equal "800", @geo.to_s - end - - should "generate correct ImageMagick formatting string for xH-formatted string" do - assert @geo = Paperclip::Geometry.parse("x600") - assert_equal "x600", @geo.to_s - end - - should "generate correct ImageMagick formatting string for WxH-formatted string" do - assert @geo = Paperclip::Geometry.parse("800x600") - assert_equal "800x600", @geo.to_s - end - - should "be generated from a file" do - file = File.join(File.dirname(__FILE__), "fixtures", "5k.png") - file = File.new(file, 'rb') - assert_nothing_raised{ @geo = Paperclip::Geometry.from_file(file) } - assert @geo.height > 0 - assert @geo.width > 0 - end - - should "be generated from a file path" do - file = File.join(File.dirname(__FILE__), "fixtures", "5k.png") - assert_nothing_raised{ @geo = Paperclip::Geometry.from_file(file) } - assert @geo.height > 0 - assert @geo.width > 0 - end - - should "not generate from a bad file" do - file = "/home/This File Does Not Exist.omg" - assert_raise(Paperclip::NotIdentifiedByImageMagickError){ @geo = Paperclip::Geometry.from_file(file) } - end - - should "not generate from a blank filename" do - file = "" - assert_raise(Paperclip::NotIdentifiedByImageMagickError){ @geo = Paperclip::Geometry.from_file(file) } - end - - should "not generate from a nil file" do - file = nil - assert_raise(Paperclip::NotIdentifiedByImageMagickError){ @geo = Paperclip::Geometry.from_file(file) } - end - - should "not generate from a file with no path" do - file = mock("file", :path => "") - file.stubs(:respond_to?).with(:path).returns(true) - assert_raise(Paperclip::NotIdentifiedByImageMagickError){ @geo = Paperclip::Geometry.from_file(file) } - end - - should "let us know when a command isn't found versus a processing error" do - old_path = ENV['PATH'] - begin - ENV['PATH'] = '' - assert_raises(Paperclip::CommandNotFoundError) do - file = File.join(File.dirname(__FILE__), "fixtures", "5k.png") - @geo = Paperclip::Geometry.from_file(file) - end - ensure - ENV['PATH'] = old_path - end - end - - [['vertical', 900, 1440, true, false, false, 1440, 900, 0.625], - ['horizontal', 1024, 768, false, true, false, 1024, 768, 1.3333], - ['square', 100, 100, false, false, true, 100, 100, 1]].each do |args| - context "performing calculations on a #{args[0]} viewport" do - setup do - @geo = Paperclip::Geometry.new(args[1], args[2]) - end - - should "#{args[3] ? "" : "not"} be vertical" do - assert_equal args[3], @geo.vertical? - end - - should "#{args[4] ? "" : "not"} be horizontal" do - assert_equal args[4], @geo.horizontal? - end - - should "#{args[5] ? "" : "not"} be square" do - assert_equal args[5], @geo.square? - end - - should "report that #{args[6]} is the larger dimension" do - assert_equal args[6], @geo.larger - end - - should "report that #{args[7]} is the smaller dimension" do - assert_equal args[7], @geo.smaller - end - - should "have an aspect ratio of #{args[8]}" do - assert_in_delta args[8], @geo.aspect, 0.0001 - end - end - end - - [[ [1000, 100], [64, 64], "x64", "64x64+288+0" ], - [ [100, 1000], [50, 950], "x950", "50x950+22+0" ], - [ [100, 1000], [50, 25], "50x", "50x25+0+237" ]]. each do |args| - context "of #{args[0].inspect} and given a Geometry #{args[1].inspect} and sent transform_to" do - setup do - @geo = Paperclip::Geometry.new(*args[0]) - @dst = Paperclip::Geometry.new(*args[1]) - @scale, @crop = @geo.transformation_to @dst, true - end - - should "be able to return the correct scaling transformation geometry #{args[2]}" do - assert_equal args[2], @scale - end - - should "be able to return the correct crop transformation geometry #{args[3]}" do - assert_equal args[3], @crop - end - end - end - end -end diff --git a/test/helper.rb b/test/helper.rb deleted file mode 100644 index 8ed11886b..000000000 --- a/test/helper.rb +++ /dev/null @@ -1,181 +0,0 @@ -require 'rubygems' -require 'tempfile' -require 'pathname' -require 'test/unit' - -require 'shoulda' -require 'mocha' - -require 'active_record' -require 'active_record/version' -require 'active_support' -require 'mime/types' -require 'pathname' - -require 'pathname' - -puts "Testing against version #{ActiveRecord::VERSION::STRING}" - -`ruby -e 'exit 0'` # Prime $? with a value. - -begin - require 'ruby-debug' -rescue LoadError => e - puts "debugger disabled" -end - -ROOT = Pathname(File.expand_path(File.join(File.dirname(__FILE__), '..'))) - -def silence_warnings - old_verbose, $VERBOSE = $VERBOSE, nil - yield -ensure - $VERBOSE = old_verbose -end - -class Test::Unit::TestCase - def setup - silence_warnings do - Object.const_set(:Rails, stub('Rails', :root => ROOT, :env => 'test')) - end - end -end - -$LOAD_PATH << File.join(ROOT, 'lib') -$LOAD_PATH << File.join(ROOT, 'lib', 'paperclip') - -require File.join(ROOT, 'lib', 'paperclip.rb') - -require './shoulda_macros/paperclip' - -FIXTURES_DIR = File.join(File.dirname(__FILE__), "fixtures") -config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) -ActiveRecord::Base.logger = ActiveSupport::BufferedLogger.new(File.dirname(__FILE__) + "/debug.log") -ActiveRecord::Base.establish_connection(config['test']) -Paperclip.options[:logger] = ActiveRecord::Base.logger - -def require_everything_in_directory(directory_name) - Dir[File.join(File.dirname(__FILE__), directory_name, '*')].each do |f| - require f - end -end - -require_everything_in_directory('support') - -def reset_class class_name - ActiveRecord::Base.send(:include, Paperclip::Glue) - Object.send(:remove_const, class_name) rescue nil - klass = Object.const_set(class_name, Class.new(ActiveRecord::Base)) - klass.class_eval{ include Paperclip::Glue } - klass -end - -def reset_table table_name, &block - block ||= lambda { |table| true } - ActiveRecord::Base.connection.create_table :dummies, {:force => true}, &block -end - -def modify_table table_name, &block - ActiveRecord::Base.connection.change_table :dummies, &block -end - -def rebuild_model options = {} - ActiveRecord::Base.connection.create_table :dummies, :force => true do |table| - table.column :title, :string - table.column :other, :string - table.column :avatar_file_name, :string - table.column :avatar_content_type, :string - table.column :avatar_file_size, :integer - table.column :avatar_updated_at, :datetime - table.column :avatar_fingerprint, :string - end - rebuild_class options -end - -def rebuild_class options = {} - ActiveRecord::Base.send(:include, Paperclip::Glue) - Object.send(:remove_const, "Dummy") rescue nil - Object.const_set("Dummy", Class.new(ActiveRecord::Base)) - Paperclip.reset_duplicate_clash_check! - Dummy.class_eval do - include Paperclip::Glue - has_attached_file :avatar, options - end - Dummy.reset_column_information -end - -class FakeModel - attr_accessor :avatar_file_name, - :avatar_file_size, - :avatar_updated_at, - :avatar_content_type, - :avatar_fingerprint, - :id - - def errors - @errors ||= [] - end - - def run_paperclip_callbacks name, *args - end - -end - -def attachment options - Paperclip::Attachment.new(:avatar, FakeModel.new, options) -end - -def silence_warnings - old_verbose, $VERBOSE = $VERBOSE, nil - yield -ensure - $VERBOSE = old_verbose -end - -def should_accept_dummy_class - should "accept the class" do - assert_accepts @matcher, @dummy_class - end - - should "accept an instance of that class" do - assert_accepts @matcher, @dummy_class.new - end -end - -def should_reject_dummy_class - should "reject the class" do - assert_rejects @matcher, @dummy_class - end - - should "reject an instance of that class" do - assert_rejects @matcher, @dummy_class.new - end -end - -def with_exitstatus_returning(code) - saved_exitstatus = $?.nil? ? 0 : $?.exitstatus - begin - `ruby -e 'exit #{code.to_i}'` - yield - ensure - `ruby -e 'exit #{saved_exitstatus.to_i}'` - end -end - -def fixture_file(filename) - File.join(File.dirname(__FILE__), 'fixtures', filename) -end - -def assert_success_response(url) - Net::HTTP.get_response(URI.parse(url)) do |response| - assert_equal "200", response.code, - "Expected HTTP response code 200, got #{response.code}" - end -end - -def assert_not_found_response(url) - Net::HTTP.get_response(URI.parse(url)) do |response| - assert_equal "404", response.code, - "Expected HTTP response code 404, got #{response.code}" - end -end diff --git a/test/integration_test.rb b/test/integration_test.rb deleted file mode 100644 index 56936d054..000000000 --- a/test/integration_test.rb +++ /dev/null @@ -1,652 +0,0 @@ -require './test/helper' - -class IntegrationTest < Test::Unit::TestCase - context "Many models at once" do - setup do - rebuild_model - @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') - 300.times do |i| - Dummy.create! :avatar => @file - end - end - - should "not exceed the open file limit" do - assert_nothing_raised do - dummies = Dummy.find(:all) - dummies.each { |dummy| dummy.avatar } - end - end - end - - context "An attachment" do - setup do - rebuild_model :styles => { :thumb => "50x50#" } - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - @dummy.avatar = @file - assert @dummy.save - end - - teardown { @file.close } - - should "create its thumbnails properly" do - assert_match /\b50x50\b/, `identify "#{@dummy.avatar.path(:thumb)}"` - end - - context 'reprocessing with unreadable original' do - setup { File.chmod(0000, @dummy.avatar.path) } - - should "not raise an error" do - assert_nothing_raised do - @dummy.avatar.reprocess! - end - end - - should "return false" do - assert ! @dummy.avatar.reprocess! - end - - teardown { File.chmod(0644, @dummy.avatar.path) } - end - - context "redefining its attachment styles" do - setup do - Dummy.class_eval do - has_attached_file :avatar, :styles => { :thumb => "150x25#" } - has_attached_file :avatar, :styles => { :thumb => "150x25#", :dynamic => lambda { |a| '50x50#' } } - end - @d2 = Dummy.find(@dummy.id) - @original_timestamp = @d2.avatar_updated_at - @d2.avatar.reprocess! - @d2.save - end - - should "create its thumbnails properly" do - assert_match /\b150x25\b/, `identify "#{@dummy.avatar.path(:thumb)}"` - assert_match /\b50x50\b/, `identify "#{@dummy.avatar.path(:dynamic)}"` - end - - should "change the timestamp" do - assert_not_equal @original_timestamp, @d2.avatar_updated_at - end - - should "clean up the old original if it is a tempfile" do - original = @d2.avatar.to_file(:original) - tf = Paperclip::Tempfile.new('original') - tf.binmode - original.binmode - tf.write(original.read) - original.close - tf.rewind - - @d2.avatar.expects(:to_file).with(:original).returns(tf) - - @d2.avatar.reprocess! - end - end - end - - context "Attachment" do - setup do - @thumb_path = "./test/../public/system/avatars/1/thumb/5k.png" - File.delete(@thumb_path) if File.exists?(@thumb_path) - rebuild_model :styles => { :thumb => "50x50#" } - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - - end - - teardown { @file.close } - - should "not create the thumbnails upon saving when post-processing is disabled" do - @dummy.avatar.post_processing = false - @dummy.avatar = @file - assert @dummy.save - assert !File.exists?(@thumb_path) - end - - should "create the thumbnails upon saving when post_processing is enabled" do - @dummy.avatar.post_processing = true - @dummy.avatar = @file - assert @dummy.save - assert File.exists?(@thumb_path) - end - end - - context "Attachment with no generated thumbnails" do - setup do - @thumb_small_path = "./test/../public/system/avatars/1/thumb_small/5k.png" - @thumb_large_path = "./test/../public/system/avatars/1/thumb_large/5k.png" - File.delete(@thumb_small_path) if File.exists?(@thumb_small_path) - File.delete(@thumb_large_path) if File.exists?(@thumb_large_path) - rebuild_model :styles => { :thumb_small => "50x50#", :thumb_large => "60x60#" } - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - - @dummy.avatar.post_processing = false - @dummy.avatar = @file - assert @dummy.save - @dummy.avatar.post_processing = true - end - - teardown { @file.close } - - should "allow us to create all thumbnails in one go" do - assert !File.exists?(@thumb_small_path) - assert !File.exists?(@thumb_large_path) - - @dummy.avatar.reprocess! - - assert File.exists?(@thumb_small_path) - assert File.exists?(@thumb_large_path) - end - - should "allow us to selectively create each thumbnail" do - assert !File.exists?(@thumb_small_path) - assert !File.exists?(@thumb_large_path) - - @dummy.avatar.reprocess! :thumb_small - assert File.exists?(@thumb_small_path) - assert !File.exists?(@thumb_large_path) - - @dummy.avatar.reprocess! :thumb_large - assert File.exists?(@thumb_large_path) - end - end - - context "A model that modifies its original" do - setup do - rebuild_model :styles => { :original => "2x2#" } - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - @dummy.avatar = @file - end - - should "report the file size of the processed file and not the original" do - assert_not_equal @file.size, @dummy.avatar.size - end - - teardown { @file.close } - end - - context "A model with attachments scoped under an id" do - setup do - rebuild_model :styles => { :large => "100x100", - :medium => "50x50" }, - :path => ":rails_root/tmp/:id/:attachments/:style.:extension" - @dummy = Dummy.new - @file = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - @dummy.avatar = @file - end - - teardown { @file.close } - - context "when saved" do - setup do - @dummy.save - @saved_path = @dummy.avatar.path(:large) - end - - should "have a large file in the right place" do - assert File.exists?(@dummy.avatar.path(:large)) - end - - context "and deleted" do - setup do - @dummy.avatar.clear - @dummy.save - end - - should "not have a large file in the right place anymore" do - assert ! File.exists?(@saved_path) - end - - should "not have its next two parent directories" do - assert ! File.exists?(File.dirname(@saved_path)) - assert ! File.exists?(File.dirname(File.dirname(@saved_path))) - end - - before_should "not die if an unexpected SystemCallError happens" do - FileUtils.stubs(:rmdir).raises(Errno::EPIPE) - end - end - end - end - - context "A model with no attachment validation" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :default_style => :medium, - :url => "/:attachment/:class/:style/:id/:basename.:extension", - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - @dummy = Dummy.new - end - - should "have its definition return false when asked about whiny_thumbnails" do - assert ! Dummy.attachment_definitions[:avatar][:whiny_thumbnails] - end - - context "when validates_attachment_thumbnails is called" do - setup do - Dummy.validates_attachment_thumbnails :avatar - end - - should "have its definition return true when asked about whiny_thumbnails" do - assert_equal true, Dummy.attachment_definitions[:avatar][:whiny_thumbnails] - end - end - - context "redefined to have attachment validations" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :whiny_thumbnails => true, - :default_style => :medium, - :url => "/:attachment/:class/:style/:id/:basename.:extension", - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - end - - should "have its definition return true when asked about whiny_thumbnails" do - assert_equal true, Dummy.attachment_definitions[:avatar][:whiny_thumbnails] - end - end - end - - context "A model with no convert_options setting" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :default_style => :medium, - :url => "/:attachment/:class/:style/:id/:basename.:extension", - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - @dummy = Dummy.new - end - - should "have its definition return nil when asked about convert_options" do - assert ! Dummy.attachment_definitions[:avatar][:convert_options] - end - - context "redefined to have convert_options setting" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :convert_options => "-strip -depth 8", - :default_style => :medium, - :url => "/:attachment/:class/:style/:id/:basename.:extension", - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - end - - should "have its definition return convert_options value when asked about convert_options" do - assert_equal "-strip -depth 8", Dummy.attachment_definitions[:avatar][:convert_options] - end - end - end - - context "A model with no source_file_options setting" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :default_style => :medium, - :url => "/:attachment/:class/:style/:id/:basename.:extension", - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - @dummy = Dummy.new - end - - should "have its definition return nil when asked about source_file_options" do - assert ! Dummy.attachment_definitions[:avatar][:source_file_options] - end - - context "redefined to have source_file_options setting" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :source_file_options => "-density 400", - :default_style => :medium, - :url => "/:attachment/:class/:style/:id/:basename.:extension", - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - end - - should "have its definition return source_file_options value when asked about source_file_options" do - assert_equal "-density 400", Dummy.attachment_definitions[:avatar][:source_file_options] - end - end - end - - context "A model with a filesystem attachment" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :whiny_thumbnails => true, - :default_style => :medium, - :url => "/:attachment/:class/:style/:id/:basename.:extension", - :path => ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension" - @dummy = Dummy.new - @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') - @bad_file = File.new(File.join(FIXTURES_DIR, "bad.png"), 'rb') - - assert @dummy.avatar = @file - assert @dummy.valid?, @dummy.errors.full_messages.join(", ") - assert @dummy.save - end - - should "write and delete its files" do - [["434x66", :original], - ["300x46", :large], - ["100x15", :medium], - ["32x32", :thumb]].each do |geo, style| - cmd = %Q[identify -format "%wx%h" "#{@dummy.avatar.path(style)}"] - assert_equal geo, `#{cmd}`.chomp, cmd - end - - saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) } - - @d2 = Dummy.find(@dummy.id) - assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path}"`.chomp - assert_equal "434x66", `identify -format "%wx%h" "#{@d2.avatar.path(:original)}"`.chomp - assert_equal "300x46", `identify -format "%wx%h" "#{@d2.avatar.path(:large)}"`.chomp - assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path(:medium)}"`.chomp - assert_equal "32x32", `identify -format "%wx%h" "#{@d2.avatar.path(:thumb)}"`.chomp - - @dummy.avatar = "not a valid file but not nil" - assert_equal File.basename(@file.path), @dummy.avatar_file_name - assert @dummy.valid? - assert @dummy.save - - saved_paths.each do |p| - assert File.exists?(p) - end - - @dummy.avatar.clear - assert_nil @dummy.avatar_file_name - assert @dummy.valid? - assert @dummy.save - - saved_paths.each do |p| - assert ! File.exists?(p) - end - - @d2 = Dummy.find(@dummy.id) - assert_nil @d2.avatar_file_name - end - - should "work exactly the same when new as when reloaded" do - @d2 = Dummy.find(@dummy.id) - - assert_equal @dummy.avatar_file_name, @d2.avatar_file_name - [:thumb, :medium, :large, :original].each do |style| - assert_equal @dummy.avatar.path(style), @d2.avatar.path(style) - end - - saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) } - - @d2.avatar.clear - assert @d2.save - - saved_paths.each do |p| - assert ! File.exists?(p) - end - end - - should "know the difference between good files, bad files, and not files" do - expected = @dummy.avatar.to_file - @dummy.avatar = "not a file" - assert @dummy.valid? - assert_equal expected.path, @dummy.avatar.path - expected.close - - @dummy.avatar = @bad_file - assert ! @dummy.valid? - end - - should "know the difference between good files, bad files, and not files when validating" do - Dummy.validates_attachment_presence :avatar - @d2 = Dummy.find(@dummy.id) - @d2.avatar = @file - assert @d2.valid?, @d2.errors.full_messages.inspect - @d2.avatar = @bad_file - assert ! @d2.valid? - end - - should "be able to reload without saving and not have the file disappear" do - @dummy.avatar = @file - assert @dummy.save - @dummy.avatar.clear - assert_nil @dummy.avatar_file_name - @dummy.reload - assert_equal "5k.png", @dummy.avatar_file_name - end - - [000,002,022].each do |umask| - context "when the umask is #{umask}" do - setup do - @umask = File.umask umask - end - - teardown do - File.umask @umask - end - - should "respect the current umask" do - @dummy.avatar = @file - @dummy.save - assert_equal 0666&~umask, 0666&File.stat(@dummy.avatar.path).mode - end - end - end - - context "that is assigned its file from another Paperclip attachment" do - setup do - @dummy2 = Dummy.new - @file2 = File.new(File.join(FIXTURES_DIR, "12k.png"), 'rb') - assert @dummy2.avatar = @file2 - @dummy2.save - end - - should "work when assigned a file" do - assert_not_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`, - `identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"` - - assert @dummy.avatar = @dummy2.avatar - @dummy.save - assert_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`, - `identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"` - assert_equal @dummy.avatar_file_name, @dummy2.avatar_file_name - end - end - - end - - context "A model with an attachments association and a Paperclip attachment" do - setup do - Dummy.class_eval do - has_many :attachments, :class_name => 'Dummy' - end - - @dummy = Dummy.new - @dummy.avatar = File.new(File.join(File.dirname(__FILE__), - "fixtures", - "5k.png"), 'rb') - end - - should "should not error when saving" do - assert_nothing_raised do - @dummy.save! - end - end - end - - if ENV['S3_TEST_BUCKET'] - def s3_files_for attachment - [:thumb, :medium, :large, :original].inject({}) do |files, style| - data = `curl "#{attachment.url(style)}" 2>/dev/null`.chomp - t = Tempfile.new("paperclip-test") - t.binmode - t.write(data) - t.rewind - files[style] = t - files - end - end - - def s3_headers_for attachment, style - `curl --head "#{attachment.url(style)}" 2>/dev/null`.split("\n").inject({}) do |h,head| - split_head = head.chomp.split(/\s*:\s*/, 2) - h[split_head.first.downcase] = split_head.last unless split_head.empty? - h - end - end - - context "A model with an S3 attachment" do - setup do - rebuild_model :styles => { :large => "300x300>", - :medium => "100x100", - :thumb => ["32x32#", :gif] }, - :storage => :s3, - :whiny_thumbnails => true, - :s3_credentials => File.new(File.join(File.dirname(__FILE__), "s3.yml")), - :default_style => :medium, - :bucket => ENV['S3_TEST_BUCKET'], - :path => ":class/:attachment/:id/:style/:basename.:extension" - @dummy = Dummy.new - @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') - @bad_file = File.new(File.join(FIXTURES_DIR, "bad.png"), 'rb') - - assert @dummy.avatar = @file - assert @dummy.valid? - assert @dummy.save - - @files_on_s3 = s3_files_for @dummy.avatar - end - - context 'assigning itself to a new model' do - setup do - @d2 = Dummy.new - @d2.avatar = @dummy.avatar - @d2.save - end - - should "have the same name as the old file" do - assert_equal @d2.avatar.original_filename, @dummy.avatar.original_filename - end - end - - should "have the same contents as the original" do - @file.rewind - assert_equal @file.read, @files_on_s3[:original].read - end - - should "write and delete its files" do - [["434x66", :original], - ["300x46", :large], - ["100x15", :medium], - ["32x32", :thumb]].each do |geo, style| - cmd = %Q[identify -format "%wx%h" "#{@files_on_s3[style].path}"] - assert_equal geo, `#{cmd}`.chomp, cmd - end - - @d2 = Dummy.find(@dummy.id) - @d2_files = s3_files_for @d2.avatar - [["434x66", :original], - ["300x46", :large], - ["100x15", :medium], - ["32x32", :thumb]].each do |geo, style| - cmd = %Q[identify -format "%wx%h" "#{@d2_files[style].path}"] - assert_equal geo, `#{cmd}`.chomp, cmd - end - - @dummy.avatar = "not a valid file but not nil" - assert_equal File.basename(@file.path), @dummy.avatar_file_name - assert @dummy.valid? - assert @dummy.save - - [:thumb, :medium, :large, :original].each do |style| - assert @dummy.avatar.exists?(style) - end - - @dummy.avatar.clear - assert_nil @dummy.avatar_file_name - assert @dummy.valid? - assert @dummy.save - - [:thumb, :medium, :large, :original].each do |style| - assert ! @dummy.avatar.exists?(style) - end - - @d2 = Dummy.find(@dummy.id) - assert_nil @d2.avatar_file_name - end - - should "work exactly the same when new as when reloaded" do - @d2 = Dummy.find(@dummy.id) - - assert_equal @dummy.avatar_file_name, @d2.avatar_file_name - [:thumb, :medium, :large, :original].each do |style| - assert_equal @dummy.avatar.to_file(style).read, @d2.avatar.to_file(style).read - end - - saved_keys = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.to_file(s) } - - @d2.avatar.clear - assert @d2.save - - [:thumb, :medium, :large, :original].each do |style| - assert ! @dummy.avatar.exists?(style) - end - end - - should "know the difference between good files, bad files, not files, and nil" do - expected = @dummy.avatar.to_file - @dummy.avatar = "not a file" - assert @dummy.valid? - assert_equal expected.read, @dummy.avatar.to_file.read - - @dummy.avatar = @bad_file - assert ! @dummy.valid? - @dummy.avatar = nil - assert @dummy.valid? - - Dummy.validates_attachment_presence :avatar - @d2 = Dummy.find(@dummy.id) - @d2.avatar = @file - assert @d2.valid? - @d2.avatar = @bad_file - assert ! @d2.valid? - @d2.avatar = nil - assert ! @d2.valid? - end - - should "be able to reload without saving and not have the file disappear" do - @dummy.avatar = @file - assert @dummy.save - @dummy.avatar = nil - assert_nil @dummy.avatar_file_name - @dummy.reload - assert_equal "5k.png", @dummy.avatar_file_name - end - - should "have the right content type" do - headers = s3_headers_for(@dummy.avatar, :original) - assert_equal 'image/png', headers['content-type'] - end - end - end -end - diff --git a/test/iostream_test.rb b/test/iostream_test.rb deleted file mode 100644 index 15ef5c5f4..000000000 --- a/test/iostream_test.rb +++ /dev/null @@ -1,71 +0,0 @@ -require './test/helper' - -class IOStreamTest < Test::Unit::TestCase - include IOStream - context "A file" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') - end - - teardown { @file.close } - - context "that is sent #stream_to" do - - context "and given a String" do - setup do - FileUtils.mkdir_p(File.join(ROOT, 'tmp')) - assert @result = stream_to(@file, File.join(ROOT, 'tmp', 'iostream.string.test')) - end - - should "return a File" do - assert @result.is_a?(File) - end - - should "contain the same data as the original file" do - @file.rewind; @result.rewind - assert_equal @file.read, @result.read - end - end - - context "and given a Tempfile" do - setup do - tempfile = Tempfile.new('iostream.test') - tempfile.binmode - assert @result = stream_to(@file, tempfile) - end - - should "return a Tempfile" do - assert @result.is_a?(Tempfile) - end - - should "contain the same data as the original file" do - @file.rewind; @result.rewind - assert_equal @file.read, @result.read - end - end - - end - - context "that is converted #to_tempfile" do - setup do - assert @tempfile = to_tempfile(@file) - end - - should "convert it to a Paperclip Tempfile" do - assert @tempfile.is_a?(Paperclip::Tempfile) - end - - should "have the name be based on the original_filename" do - name = File.basename(@file.path) - extension = File.extname(name) - basename = File.basename(name, extension) - assert_match %r[^stream.*?#{Regexp.quote(extension)}], File.basename(@tempfile.path) - end - - should "have the Tempfile contain the same data as the file" do - @file.rewind; @tempfile.rewind - assert_equal @file.read, @tempfile.read - end - end - end -end diff --git a/test/matchers/have_attached_file_matcher_test.rb b/test/matchers/have_attached_file_matcher_test.rb deleted file mode 100644 index 3e0821c60..000000000 --- a/test/matchers/have_attached_file_matcher_test.rb +++ /dev/null @@ -1,24 +0,0 @@ -require './test/helper' - -class HaveAttachedFileMatcherTest < Test::Unit::TestCase - context "have_attached_file" do - setup do - @dummy_class = reset_class "Dummy" - reset_table "dummies" - @matcher = self.class.have_attached_file(:avatar) - end - - context "given a class with no attachment" do - should_reject_dummy_class - end - - context "given a class with an attachment" do - setup do - modify_table("dummies"){|d| d.string :avatar_file_name } - @dummy_class.has_attached_file :avatar - end - - should_accept_dummy_class - end - end -end diff --git a/test/matchers/validate_attachment_content_type_matcher_test.rb b/test/matchers/validate_attachment_content_type_matcher_test.rb deleted file mode 100644 index 35d9a1520..000000000 --- a/test/matchers/validate_attachment_content_type_matcher_test.rb +++ /dev/null @@ -1,110 +0,0 @@ -require './test/helper' - -class ValidateAttachmentContentTypeMatcherTest < Test::Unit::TestCase - context "validate_attachment_content_type" do - setup do - reset_table("dummies") do |d| - d.string :title - d.string :avatar_file_name - d.string :avatar_content_type - end - @dummy_class = reset_class "Dummy" - @dummy_class.has_attached_file :avatar - @matcher = self.class.validate_attachment_content_type(:avatar). - allowing(%w(image/png image/jpeg)). - rejecting(%w(audio/mp3 application/octet-stream)) - end - - context "given a class with no validation" do - should_reject_dummy_class - end - - context "given a class with a validation that doesn't match" do - setup do - @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{audio/.*} - end - - should_reject_dummy_class - end - - context "given a class with a matching validation" do - setup do - @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{image/.*} - end - - should_accept_dummy_class - end - - context "given a class with other validations but matching types" do - setup do - @dummy_class.validates_presence_of :title - @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{image/.*} - end - - should_accept_dummy_class - end - - context "given a class that matches and a matcher that only specifies 'allowing'" do - setup do - @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{image/.*} - @matcher = self.class.validate_attachment_content_type(:avatar). - allowing(%w(image/png image/jpeg)) - end - - should_accept_dummy_class - end - - context "given a class that does not match and a matcher that only specifies 'allowing'" do - setup do - @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{audio/.*} - @matcher = self.class.validate_attachment_content_type(:avatar). - allowing(%w(image/png image/jpeg)) - end - - should_reject_dummy_class - end - - context "given a class that matches and a matcher that only specifies 'rejecting'" do - setup do - @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{image/.*} - @matcher = self.class.validate_attachment_content_type(:avatar). - rejecting(%w(audio/mp3 application/octet-stream)) - end - - should_accept_dummy_class - end - - context "given a class that does not match and a matcher that only specifies 'rejecting'" do - setup do - @dummy_class.validates_attachment_content_type :avatar, :content_type => %r{audio/.*} - @matcher = self.class.validate_attachment_content_type(:avatar). - rejecting(%w(audio/mp3 application/octet-stream)) - end - - should_reject_dummy_class - end - - context "using an :if to control the validation" do - setup do - @dummy_class.class_eval do - validates_attachment_content_type :avatar, :content_type => %r{image/*} , :if => :go - attr_accessor :go - end - @matcher = self.class.validate_attachment_content_type(:avatar). - allowing(%w(image/png image/jpeg)). - rejecting(%w(audio/mp3 application/octet-stream)) - @dummy = @dummy_class.new - end - - should "run the validation if the control is true" do - @dummy.go = true - assert_accepts @matcher, @dummy - end - - should "not run the validation if the control is false" do - @dummy.go = false - assert_rejects @matcher, @dummy - end - end - end -end diff --git a/test/matchers/validate_attachment_presence_matcher_test.rb b/test/matchers/validate_attachment_presence_matcher_test.rb deleted file mode 100644 index 341e4f883..000000000 --- a/test/matchers/validate_attachment_presence_matcher_test.rb +++ /dev/null @@ -1,47 +0,0 @@ -require './test/helper' - -class ValidateAttachmentPresenceMatcherTest < Test::Unit::TestCase - context "validate_attachment_presence" do - setup do - reset_table("dummies") do |d| - d.string :avatar_file_name - end - @dummy_class = reset_class "Dummy" - @dummy_class.has_attached_file :avatar - @matcher = self.class.validate_attachment_presence(:avatar) - end - - context "given a class with no validation" do - should_reject_dummy_class - end - - context "given a class with a matching validation" do - setup do - @dummy_class.validates_attachment_presence :avatar - end - - should_accept_dummy_class - end - - context "using an :if to control the validation" do - setup do - @dummy_class.class_eval do - validates_attachment_presence :avatar, :if => :go - attr_accessor :go - end - @dummy = @dummy_class.new - @dummy.avatar = nil - end - - should "run the validation if the control is true" do - @dummy.go = true - assert_accepts @matcher, @dummy - end - - should "not run the validation if the control is false" do - @dummy.go = false - assert_rejects @matcher, @dummy - end - end - end -end diff --git a/test/matchers/validate_attachment_size_matcher_test.rb b/test/matchers/validate_attachment_size_matcher_test.rb deleted file mode 100644 index 0285ce348..000000000 --- a/test/matchers/validate_attachment_size_matcher_test.rb +++ /dev/null @@ -1,72 +0,0 @@ -require './test/helper' - -class ValidateAttachmentSizeMatcherTest < Test::Unit::TestCase - context "validate_attachment_size" do - setup do - reset_table("dummies") do |d| - d.string :avatar_file_name - d.integer :avatar_file_size - end - @dummy_class = reset_class "Dummy" - @dummy_class.has_attached_file :avatar - end - - context "of limited size" do - setup{ @matcher = self.class.validate_attachment_size(:avatar).in(256..1024) } - - context "given a class with no validation" do - should_reject_dummy_class - end - - context "given a class with a validation that's too high" do - setup { @dummy_class.validates_attachment_size :avatar, :in => 256..2048 } - should_reject_dummy_class - end - - context "given a class with a validation that's too low" do - setup { @dummy_class.validates_attachment_size :avatar, :in => 0..1024 } - should_reject_dummy_class - end - - context "given a class with a validation that matches" do - setup { @dummy_class.validates_attachment_size :avatar, :in => 256..1024 } - should_accept_dummy_class - end - end - - context "validates_attachment_size with infinite range" do - setup{ @matcher = self.class.validate_attachment_size(:avatar) } - - context "given a class with an upper limit" do - setup { @dummy_class.validates_attachment_size :avatar, :less_than => 1 } - should_accept_dummy_class - end - - context "given a class with no upper limit" do - setup { @dummy_class.validates_attachment_size :avatar, :greater_than => 1 } - should_accept_dummy_class - end - end - - context "using an :if to control the validation" do - setup do - @dummy_class.class_eval do - validates_attachment_size :avatar, :greater_than => 1024, :if => :go - attr_accessor :go - end - @dummy = @dummy_class.new - @matcher = self.class.validate_attachment_size(:avatar).greater_than(1024) - end - - should "run the validation if the control is true" do - @dummy.go = true - assert_accepts @matcher, @dummy - end - - should "not run the validation if the control is false" do - @dummy.go = false - assert_rejects @matcher, @dummy - end - end - end -end diff --git a/test/paperclip_missing_attachment_styles_test.rb b/test/paperclip_missing_attachment_styles_test.rb deleted file mode 100644 index 8ffdd534a..000000000 --- a/test/paperclip_missing_attachment_styles_test.rb +++ /dev/null @@ -1,96 +0,0 @@ -require './test/helper' - -class PaperclipMissingAttachmentStylesTest < Test::Unit::TestCase - - context "Paperclip" do - setup do - Paperclip.classes_with_attachments = Set.new - end - - teardown do - File.unlink(Paperclip.registered_attachments_styles_path) rescue nil - end - - should "be able to keep list of models using it" do - assert_kind_of Set, Paperclip.classes_with_attachments - assert Paperclip.classes_with_attachments.empty?, 'list should be empty' - rebuild_model - assert_equal ['Dummy'].to_set, Paperclip.classes_with_attachments - end - - should "enable to get and set path to registered styles file" do - assert_equal ROOT.join('public/system/paperclip_attachments.yml').to_s, Paperclip.registered_attachments_styles_path - Paperclip.registered_attachments_styles_path = '/tmp/config/paperclip_attachments.yml' - assert_equal '/tmp/config/paperclip_attachments.yml', Paperclip.registered_attachments_styles_path - Paperclip.registered_attachments_styles_path = nil - assert_equal ROOT.join('public/system/paperclip_attachments.yml').to_s, Paperclip.registered_attachments_styles_path - end - - should "be able to get current attachment styles" do - assert_equal Hash.new, Paperclip.send(:current_attachments_styles) - rebuild_model :styles => {:croppable => '600x600>', :big => '1000x1000>'} - expected_hash = { :Dummy => {:avatar => [:big, :croppable]}} - assert_equal expected_hash, Paperclip.send(:current_attachments_styles) - end - - should "be able to save current attachment styles for further comparison" do - rebuild_model :styles => {:croppable => '600x600>', :big => '1000x1000>'} - Paperclip.save_current_attachments_styles! - expected_hash = { :Dummy => {:avatar => [:big, :croppable]}} - assert_equal expected_hash, YAML.load_file(Paperclip.registered_attachments_styles_path) - end - - should "be able to read registered attachment styles from file" do - rebuild_model :styles => {:croppable => '600x600>', :big => '1000x1000>'} - Paperclip.save_current_attachments_styles! - expected_hash = { :Dummy => {:avatar => [:big, :croppable]}} - assert_equal expected_hash, Paperclip.send(:get_registered_attachments_styles) - end - - should "be able to calculate differences between registered styles and current styles" do - rebuild_model :styles => {:croppable => '600x600>', :big => '1000x1000>'} - Paperclip.save_current_attachments_styles! - rebuild_model :styles => {:thumb => 'x100', :export => 'x400>', :croppable => '600x600>', :big => '1000x1000>'} - expected_hash = { :Dummy => {:avatar => [:export, :thumb]} } - assert_equal expected_hash, Paperclip.missing_attachments_styles - - ActiveRecord::Base.connection.create_table :books, :force => true - class ::Book < ActiveRecord::Base - has_attached_file :cover, :styles => {:small => 'x100', :large => '1000x1000>'} - has_attached_file :sample, :styles => {:thumb => 'x100'} - end - - expected_hash = { - :Dummy => {:avatar => [:export, :thumb]}, - :Book => {:sample => [:thumb], :cover => [:large, :small]} - } - assert_equal expected_hash, Paperclip.missing_attachments_styles - Paperclip.save_current_attachments_styles! - assert_equal Hash.new, Paperclip.missing_attachments_styles - end - - should "be able to calculate differences when a new attachment is added to a model" do - rebuild_model :styles => {:croppable => '600x600>', :big => '1000x1000>'} - Paperclip.save_current_attachments_styles! - - class ::Dummy - has_attached_file :photo, :styles => {:small => 'x100', :large => '1000x1000>'} - end - - expected_hash = { - :Dummy => {:photo => [:large, :small]} - } - assert_equal expected_hash, Paperclip.missing_attachments_styles - Paperclip.save_current_attachments_styles! - assert_equal Hash.new, Paperclip.missing_attachments_styles - end - - # It's impossible to build styles hash without loading from database whole bunch of records - should "skip lambda-styles" do - rebuild_model :styles => lambda{ |attachment| attachment.instance.other == 'a' ? {:thumb => "50x50#"} : {:large => "400x400"} } - assert_equal Hash.new, Paperclip.send(:current_attachments_styles) - end - - end - -end diff --git a/test/paperclip_test.rb b/test/paperclip_test.rb deleted file mode 100644 index c11357c76..000000000 --- a/test/paperclip_test.rb +++ /dev/null @@ -1,390 +0,0 @@ -require './test/helper' - -class PaperclipTest < Test::Unit::TestCase - context "Calling Paperclip.run" do - setup do - Paperclip.options[:log_command] = false - Cocaine::CommandLine.expects(:new).with("convert", "stuff", {}).returns(stub(:run)) - @original_command_line_path = Cocaine::CommandLine.path - end - - teardown do - Paperclip.options[:log_command] = true - Cocaine::CommandLine.path = @original_command_line_path - end - - should "run the command with Cocaine" do - Paperclip.run("convert", "stuff") - end - - should "save Cocaine::CommandLine.path that set before" do - Cocaine::CommandLine.path = "/opt/my_app/bin" - Paperclip.run("convert", "stuff") - assert_equal [Cocaine::CommandLine.path].flatten.include?("/opt/my_app/bin"), true - end - end - - context "Calling Paperclip.run with a logger" do - should "pass the defined logger if :log_command is set" do - Paperclip.options[:log_command] = true - Cocaine::CommandLine.expects(:new).with("convert", "stuff", :logger => Paperclip.logger).returns(stub(:run)) - Paperclip.run("convert", "stuff") - end - end - - context "Paperclip.each_instance_with_attachment" do - setup do - @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') - d1 = Dummy.create(:avatar => @file) - d2 = Dummy.create - d3 = Dummy.create(:avatar => @file) - @expected = [d1, d3] - end - should "yield every instance of a model that has an attachment" do - actual = [] - Paperclip.each_instance_with_attachment("Dummy", "avatar") do |instance| - actual << instance - end - assert_same_elements @expected, actual - end - end - - should "raise when sent #processor and the name of a class that doesn't exist" do - assert_raises(NameError){ Paperclip.processor(:boogey_man) } - end - - should "return a class when sent #processor and the name of a class under Paperclip" do - assert_equal ::Paperclip::Thumbnail, Paperclip.processor(:thumbnail) - end - - should "get a class from a namespaced class name" do - class ::One; class Two; end; end - assert_equal ::One::Two, Paperclip.class_for("One::Two") - end - - should "raise when class doesn't exist in specified namespace" do - class ::Three; end - class ::Four; end - assert_raise NameError do - Paperclip.class_for("Three::Four") - end - end - - context "Attachments with clashing URLs should raise error" do - setup do - class Dummy2 < ActiveRecord::Base - include Paperclip::Glue - end - end - - should "generate warning if attachment is redefined with the same url string" do - Paperclip.expects(:log).with("Duplicate URL for blah with /system/:attachment/:id/:style/:filename. This will clash with attachment defined in Dummy class") - Dummy.class_eval do - has_attached_file :blah - end - Dummy2.class_eval do - has_attached_file :blah - end - end - - should "not generate warning if attachment is redifined with the same url string but has :class in it" do - Paperclip.expects(:log).never - Dummy.class_eval do - has_attached_file :blah, :url => "/system/:class/:attachment/:id/:style/:filename" - end - Dummy2.class_eval do - has_attached_file :blah, :url => "/system/:class/:attachment/:id/:style/:filename" - end - end - end - - context "An ActiveRecord model with an 'avatar' attachment" do - setup do - rebuild_model :path => "tmp/:class/omg/:style.:extension" - @file = File.new(File.join(FIXTURES_DIR, "5k.png"), 'rb') - end - - teardown { @file.close } - - should "not error when trying to also create a 'blah' attachment" do - assert_nothing_raised do - Dummy.class_eval do - has_attached_file :blah - end - end - end - - context "that is attr_protected" do - setup do - Dummy.class_eval do - attr_protected :avatar - end - @dummy = Dummy.new - end - - should "not assign the avatar on mass-set" do - @dummy.attributes = { :other => "I'm set!", - :avatar => @file } - - assert_equal "I'm set!", @dummy.other - assert ! @dummy.avatar? - end - - should "still allow assigment on normal set" do - @dummy.other = "I'm set!" - @dummy.avatar = @file - - assert_equal "I'm set!", @dummy.other - assert @dummy.avatar? - end - end - - context "with a subclass" do - setup do - class ::SubDummy < Dummy; end - end - - should "be able to use the attachment from the subclass" do - assert_nothing_raised do - @subdummy = SubDummy.create(:avatar => @file) - end - end - - should "be able to see the attachment definition from the subclass's class" do - assert_equal "tmp/:class/omg/:style.:extension", - SubDummy.attachment_definitions[:avatar][:path] - end - - teardown do - Object.send(:remove_const, "SubDummy") rescue nil - end - end - - should "have an #avatar method" do - assert Dummy.new.respond_to?(:avatar) - end - - should "have an #avatar= method" do - assert Dummy.new.respond_to?(:avatar=) - end - - context "that is valid" do - setup do - @dummy = Dummy.new - @dummy.avatar = @file - end - - should "be valid" do - assert @dummy.valid? - end - end - - context "a validation with an if guard clause" do - context "as a lambda" do - setup do - Dummy.send(:"validates_attachment_presence", :avatar, :if => lambda{|i| i.foo }) - @dummy = Dummy.new - @dummy.stubs(:avatar_file_name).returns(nil) - end - - should "attempt validation if the guard returns true" do - @dummy.expects(:foo).returns(true) - assert ! @dummy.valid? - end - - should "not attempt validation if the guard returns false" do - @dummy.expects(:foo).returns(false) - assert @dummy.valid? - end - end - - context "as a method name" do - setup do - Dummy.send(:"validates_attachment_presence", :avatar, :if => :foo) - @dummy = Dummy.new - @dummy.stubs(:avatar_file_name).returns(nil) - end - - should "attempt validation if the guard returns true" do - @dummy.expects(:foo).returns(true) - assert ! @dummy.valid? - end - - should "not attempt validation if the guard returns false" do - @dummy.expects(:foo).returns(false) - assert @dummy.valid? - end - end - end - - context "a validation with an unless guard clause" do - context "as a lambda" do - setup do - Dummy.send(:"validates_attachment_presence", :avatar, :unless => lambda{|i| i.foo }) - @dummy = Dummy.new - @dummy.stubs(:avatar_file_name).returns(nil) - end - - should "attempt validation if the guard returns true" do - @dummy.expects(:foo).returns(false) - assert ! @dummy.valid? - end - - should "not attempt validation if the guard returns false" do - @dummy.expects(:foo).returns(true) - assert @dummy.valid? - end - end - - context "as a method name" do - setup do - Dummy.send(:"validates_attachment_presence", :avatar, :unless => :foo) - @dummy = Dummy.new - @dummy.stubs(:avatar_file_name).returns(nil) - end - - should "attempt validation if the guard returns true" do - @dummy.expects(:foo).returns(false) - assert ! @dummy.valid? - end - - should "not attempt validation if the guard returns false" do - @dummy.expects(:foo).returns(true) - assert @dummy.valid? - end - end - end - - should "not have Attachment in the ActiveRecord::Base namespace" do - assert_raises(NameError) do - ActiveRecord::Base::Attachment - end - end - - def self.should_validate validation, options, valid_file, invalid_file - context "with #{validation} validation and #{options.inspect} options" do - setup do - rebuild_class - Dummy.send(:"validates_attachment_#{validation}", :avatar, options) - @dummy = Dummy.new - end - context "and assigning nil" do - setup do - @dummy.avatar = nil - @dummy.valid? - end - if validation == :presence - should "have an error on the attachment" do - assert @dummy.errors[:avatar] - assert @dummy.errors[:avatar_file_name] - end - else - should "not have an error on the attachment" do - assert @dummy.errors.blank?, @dummy.errors.full_messages.join(", ") - end - end - end - context "and assigned a valid file" do - setup do - @dummy.avatar = valid_file - @dummy.valid? - end - should "not have an error" do - assert_equal 0, @dummy.errors.size, @dummy.errors.full_messages.join(", ") - end - end - context "and assigned an invalid file" do - setup do - @dummy.avatar = invalid_file - @dummy.valid? - end - should "have an error" do - assert @dummy.errors.size > 0 - end - end - end - end - - [[:presence, {}, "5k.png", nil], - [:size, {:in => 1..10240}, "5k.png", "12k.png"], - [:size, {:less_than => 10240}, "5k.png", "12k.png"], - [:size, {:greater_than => 8096}, "12k.png", "5k.png"], - [:content_type, {:content_type => "image/png"}, "5k.png", "text.txt"], - [:content_type, {:content_type => "text/plain"}, "text.txt", "5k.png"], - [:content_type, {:content_type => %r{image/.*}}, "5k.png", "text.txt"]].each do |args| - validation, options, valid_file, invalid_file = args - valid_file &&= File.open(File.join(FIXTURES_DIR, valid_file), "rb") - invalid_file &&= File.open(File.join(FIXTURES_DIR, invalid_file), "rb") - - should_validate validation, options, valid_file, invalid_file - end - - context "with content_type validation and lambda message" do - context "and assigned an invalid file" do - setup do - Dummy.send(:"validates_attachment_content_type", :avatar, :content_type => %r{image/.*}, :message => lambda {'lambda content type message'}) - @dummy = Dummy.new - @dummy.avatar &&= File.open(File.join(FIXTURES_DIR, "text.txt"), "rb") - @dummy.valid? - end - - should "have a content type error message" do - assert [@dummy.errors[:avatar_content_type]].flatten.any?{|error| error =~ %r/lambda content type message/ } - end - end - end - - context "with size validation and less_than 10240 option" do - context "and assigned an invalid file" do - setup do - Dummy.send(:"validates_attachment_size", :avatar, :less_than => 10240) - @dummy = Dummy.new - @dummy.avatar &&= File.open(File.join(FIXTURES_DIR, "12k.png"), "rb") - @dummy.valid? - end - - should "have a file size min/max error message" do - assert [@dummy.errors[:avatar_file_size]].flatten.any?{|error| error =~ %r/between 0 and 10240 bytes/ } - end - end - end - - context "with size validation and less_than 10240 option with lambda message" do - context "and assigned an invalid file" do - setup do - Dummy.send(:"validates_attachment_size", :avatar, :less_than => 10240, :message => lambda {'lambda between 0 and 10240 bytes'}) - @dummy = Dummy.new - @dummy.avatar &&= File.open(File.join(FIXTURES_DIR, "12k.png"), "rb") - @dummy.valid? - end - - should "have a file size min/max error message" do - assert [@dummy.errors[:avatar_file_size]].flatten.any?{|error| error =~ %r/lambda between 0 and 10240 bytes/ } - end - end - end - - end - - context "configuring a custom processor" do - setup do - @freedom_processor = Class.new do - def make(file, options = {}, attachment = nil) - file - end - end.new - - Paperclip.configure do |config| - config.register_processor(:freedom, @freedom_processor) - end - end - - should "be able to find the custom processor" do - assert_equal @freedom_processor, Paperclip.processor(:freedom) - end - - teardown do - Paperclip.clear_processors! - end - end -end diff --git a/test/processor_test.rb b/test/processor_test.rb deleted file mode 100644 index 39c9fbab1..000000000 --- a/test/processor_test.rb +++ /dev/null @@ -1,10 +0,0 @@ -require './test/helper' - -class ProcessorTest < Test::Unit::TestCase - should "instantiate and call #make when sent #make to the class" do - processor = mock - processor.expects(:make).with() - Paperclip::Processor.expects(:new).with(:one, :two, :three).returns(processor) - Paperclip::Processor.make(:one, :two, :three) - end -end diff --git a/test/schema_test.rb b/test/schema_test.rb deleted file mode 100644 index b225a8cbe..000000000 --- a/test/schema_test.rb +++ /dev/null @@ -1,98 +0,0 @@ -require './test/helper' -require 'paperclip/schema' - -class MockSchema - include Paperclip::Schema - - def initialize(table_name = nil) - @table_name = table_name - @columns = {} - @deleted_columns = [] - end - - def column(name, type) - @columns[name] = type - end - - def remove_column(table_name, column_name) - return if @table_name && @table_name != table_name - @columns.delete(column_name) - @deleted_columns.push(column_name) - end - - def has_column?(column_name) - @columns.key?(column_name) - end - - def deleted_column?(column_name) - @deleted_columns.include?(column_name) - end - - def type_of(column_name) - @columns[column_name] - end -end - -class SchemaTest < Test::Unit::TestCase - context "Migrating up" do - setup do - @schema = MockSchema.new - @schema.has_attached_file :avatar - end - - should "create the file_name column" do - assert @schema.has_column?(:avatar_file_name) - end - - should "create the content_type column" do - assert @schema.has_column?(:avatar_content_type) - end - - should "create the file_size column" do - assert @schema.has_column?(:avatar_file_size) - end - - should "create the updated_at column" do - assert @schema.has_column?(:avatar_updated_at) - end - - should "make the file_name column a string" do - assert_equal :string, @schema.type_of(:avatar_file_name) - end - - should "make the content_type column a string" do - assert_equal :string, @schema.type_of(:avatar_content_type) - end - - should "make the file_size column an integer" do - assert_equal :integer, @schema.type_of(:avatar_file_size) - end - - should "make the updated_at column a datetime" do - assert_equal :datetime, @schema.type_of(:avatar_updated_at) - end - end - - context "Migrating down" do - setup do - @schema = MockSchema.new(:users) - @schema.drop_attached_file :users, :avatar - end - - should "remove the file_name column" do - assert @schema.deleted_column?(:avatar_file_name) - end - - should "remove the content_type column" do - assert @schema.deleted_column?(:avatar_content_type) - end - - should "remove the file_size column" do - assert @schema.deleted_column?(:avatar_file_size) - end - - should "remove the updated_at column" do - assert @schema.deleted_column?(:avatar_updated_at) - end - end -end diff --git a/test/storage/filesystem_test.rb b/test/storage/filesystem_test.rb deleted file mode 100644 index 704b4b7ad..000000000 --- a/test/storage/filesystem_test.rb +++ /dev/null @@ -1,62 +0,0 @@ -require './test/helper' - -class FileSystemTest < Test::Unit::TestCase - context "Filesystem" do - setup do - rebuild_model :styles => { :thumbnail => "25x25#" } - @dummy = Dummy.create! - - @dummy.avatar = File.open(fixture_file('5k.png')) - end - - should "allow file assignment" do - assert @dummy.save - end - - should "store the original" do - @dummy.save - assert File.exists?(@dummy.avatar.path) - end - - should "store the thumbnail" do - @dummy.save - assert File.exists?(@dummy.avatar.path(:thumbnail)) - end - - should "clean up file objects" do - File.stubs(:exist?).returns(true) - Paperclip::Tempfile.any_instance.expects(:close).at_least_once() - Paperclip::Tempfile.any_instance.expects(:unlink).at_least_once() - - @dummy.save! - end - - should "always be rewound when returning from #to_file" do - assert_equal 0, @dummy.avatar.to_file.pos - @dummy.avatar.to_file.seek(10) - assert_equal 0, @dummy.avatar.to_file.pos - end - - context "with file that has space in file name" do - setup do - rebuild_model :styles => { :thumbnail => "25x25#" } - @dummy = Dummy.create! - - @dummy.avatar = File.open(fixture_file('spaced file.png')) - @dummy.save - end - - should "store the file" do - assert File.exists?(@dummy.avatar.path) - end - - should "return a replaced version for path" do - assert_match /.+\/spaced_file\.png/, @dummy.avatar.path - end - - should "return a replaced version for url" do - assert_match /.+\/spaced_file\.png/, @dummy.avatar.url - end - end - end -end diff --git a/test/storage/fog_test.rb b/test/storage/fog_test.rb deleted file mode 100644 index f0fc42c85..000000000 --- a/test/storage/fog_test.rb +++ /dev/null @@ -1,280 +0,0 @@ -require './test/helper' -require 'fog' - -Fog.mock! - -class FogTest < Test::Unit::TestCase - context "" do - - context "with credentials provided in a path string" do - setup do - rebuild_model :styles => { :medium => "300x300>", :thumb => "100x100>" }, - :storage => :fog, - :url => '/:attachment/:filename', - :fog_directory => "paperclip", - :fog_credentials => fixture_file('fog.yml') - @dummy = Dummy.new - @dummy.avatar = File.new(fixture_file('5k.png'), 'rb') - end - - should "have the proper information loading credentials from a file" do - assert_equal @dummy.avatar.fog_credentials[:provider], 'AWS' - end - end - - context "with credentials provided in a File object" do - setup do - rebuild_model :styles => { :medium => "300x300>", :thumb => "100x100>" }, - :storage => :fog, - :url => '/:attachment/:filename', - :fog_directory => "paperclip", - :fog_credentials => File.open(fixture_file('fog.yml')) - @dummy = Dummy.new - @dummy.avatar = File.new(fixture_file('5k.png'), 'rb') - end - - should "have the proper information loading credentials from a file" do - assert_equal @dummy.avatar.fog_credentials[:provider], 'AWS' - end - end - - context "with default values for path and url" do - setup do - rebuild_model :styles => { :medium => "300x300>", :thumb => "100x100>" }, - :storage => :fog, - :url => '/:attachment/:filename', - :fog_directory => "paperclip", - :fog_credentials => { - :provider => 'AWS', - :aws_access_key_id => 'AWS_ID', - :aws_secret_access_key => 'AWS_SECRET' - } - @dummy = Dummy.new - @dummy.avatar = File.new(fixture_file('5k.png'), 'rb') - end - should "be able to interpolate the path without blowing up" do - assert_equal File.expand_path(File.join(File.dirname(__FILE__), "../../public/avatars/5k.png")), - @dummy.avatar.path - end - - should "clean up file objects" do - File.stubs(:exist?).returns(true) - Paperclip::Tempfile.any_instance.expects(:close).at_least_once() - Paperclip::Tempfile.any_instance.expects(:unlink).at_least_once() - - @dummy.save! - end - end - - setup do - @fog_directory = 'papercliptests' - - @credentials = { - :provider => 'AWS', - :aws_access_key_id => 'ID', - :aws_secret_access_key => 'SECRET' - } - - @connection = Fog::Storage.new(@credentials) - @connection.directories.create( - :key => @fog_directory - ) - - @options = { - :fog_directory => @fog_directory, - :fog_credentials => @credentials, - :fog_host => nil, - :fog_file => {:cache_control => 1234}, - :path => ":attachment/:basename.:extension", - :storage => :fog - } - - rebuild_model(@options) - end - - should "be extended by the Fog module" do - assert Dummy.new.avatar.is_a?(Paperclip::Storage::Fog) - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown do - @file.close - directory = @connection.directories.new(:key => @fog_directory) - directory.files.each {|file| file.destroy} - directory.destroy - end - - should "always be rewound when returning from #to_file" do - assert_equal 0, @dummy.avatar.to_file.pos - @dummy.avatar.to_file.seek(10) - assert_equal 0, @dummy.avatar.to_file.pos - end - - should "pass the content type to the Fog::Storage::AWS::Files instance" do - Fog::Storage::AWS::Files.any_instance.expects(:create).with do |hash| - hash[:content_type] - end - @dummy.save - end - - context "without a bucket" do - setup do - @connection.directories.get(@fog_directory).destroy - end - - should "create the bucket" do - assert @dummy.save - assert @connection.directories.get(@fog_directory) - end - end - - context "with a bucket" do - should "succeed" do - assert @dummy.save - end - end - - context "without a fog_host" do - setup do - rebuild_model(@options.merge(:fog_host => nil)) - @dummy = Dummy.new - @dummy.avatar = StringIO.new('.') - @dummy.save - end - - should "provide a public url" do - assert !@dummy.avatar.url.nil? - end - end - - context "with a fog_host" do - setup do - rebuild_model(@options.merge(:fog_host => 'http://example.com')) - @dummy = Dummy.new - @dummy.avatar = StringIO.new('.') - @dummy.save - end - - should "provide a public url" do - assert @dummy.avatar.url =~ /^http:\/\/example\.com\/avatars\/stringio\.txt\?\d*$/ - end - end - - context "with a fog_host that includes a wildcard placeholder" do - setup do - rebuild_model( - :fog_directory => @fog_directory, - :fog_credentials => @credentials, - :fog_host => 'http://img%d.example.com', - :path => ":attachment/:basename.:extension", - :storage => :fog - ) - @dummy = Dummy.new - @dummy.avatar = StringIO.new('.') - @dummy.save - end - - should "provide a public url" do - assert @dummy.avatar.url =~ /^http:\/\/img[0123]\.example\.com\/avatars\/stringio\.txt\?\d*$/ - end - end - - context "with fog_public set to false" do - setup do - rebuild_model(@options.merge(:fog_public => false)) - @dummy = Dummy.new - @dummy.avatar = StringIO.new('.') - @dummy.save - end - - should 'set the @fog_public instance variable to false' do - assert_equal false, @dummy.avatar.instance_variable_get('@options')[:fog_public] - assert_equal false, @dummy.avatar.fog_public - end - end - - context "with a valid bucket name for a subdomain" do - should "provide an url in subdomain style" do - assert_match /^https:\/\/papercliptests.s3.amazonaws.com\/avatars\/5k.png\?\d*$/, @dummy.avatar.url - end - end - - context "with an invalid bucket name for a subdomain" do - setup do - rebuild_model(@options.merge(:fog_directory => "this_is_invalid")) - @dummy = Dummy.new - @dummy.avatar = @file - @dummy.save - end - - should "not match the bucket-subdomain restrictions" do - invalid_subdomains = %w(this_is_invalid in iamareallylongbucketnameiamareallylongbucketnameiamareallylongbu invalid- inval..id inval-.id inval.-id -invalid 192.168.10.2) - invalid_subdomains.each do |name| - assert_no_match Paperclip::Storage::Fog::AWS_BUCKET_SUBDOMAIN_RESTRICTON_REGEX, name - end - end - - should "provide an url in folder style" do - assert_match /^https:\/\/s3.amazonaws.com\/this_is_invalid\/avatars\/5k.png\?\d*$/, @dummy.avatar.url - end - - end - - context "with a proc for a bucket name evaluating a model method" do - setup do - @dynamic_fog_directory = 'dynamicpaperclip' - rebuild_model(@options.merge(:fog_directory => lambda { |attachment| attachment.instance.bucket_name })) - @dummy = Dummy.new - @dummy.stubs(:bucket_name).returns(@dynamic_fog_directory) - @dummy.avatar = @file - @dummy.save - end - - should "have created the bucket" do - assert @connection.directories.get(@dynamic_fog_directory).inspect - end - - end - - context "with a proc for the fog_host evaluating a model method" do - setup do - rebuild_model(@options.merge(:fog_host => lambda { |attachment| attachment.instance.fog_host })) - @dummy = Dummy.new - @dummy.stubs(:fog_host).returns('http://dynamicfoghost.com') - @dummy.avatar = @file - @dummy.save - end - - should "provide a public url" do - assert_match /http:\/\/dynamicfoghost\.com/, @dummy.avatar.url - end - end - - context "with a proc for the fog_credentials evaluating a model method" do - setup do - @dynamic_fog_credentials = { - :provider => 'AWS', - :aws_access_key_id => 'DYNAMIC_ID', - :aws_secret_access_key => 'DYNAMIC_SECRET' - } - rebuild_model(@options.merge(:fog_credentials => lambda { |attachment| attachment.instance.fog_credentials })) - @dummy = Dummy.new - @dummy.stubs(:fog_credentials).returns(@dynamic_fog_credentials) - @dummy.avatar = @file - @dummy.save - end - - should "provide a public url" do - assert_equal @dummy.avatar.fog_credentials, @dynamic_fog_credentials - end - end - end - - end -end diff --git a/test/storage/s3_live_test.rb b/test/storage/s3_live_test.rb deleted file mode 100644 index 540830cb0..000000000 --- a/test/storage/s3_live_test.rb +++ /dev/null @@ -1,138 +0,0 @@ -require './test/helper' -require 'aws' - -unless ENV["S3_BUCKET"].blank? - class S3LiveTest < Test::Unit::TestCase - - context "Generating an expiring url on a nonexistant attachment" do - setup do - rebuild_model :styles => { :thumb => "100x100", :square => "32x32#" }, - :storage => :s3, - :bucket => ENV["S3_BUCKET"], - :path => ":class/:attachment/:id/:style.:extension", - :s3_credentials => File.new(File.join(File.dirname(__FILE__), "..", "fixtures", "s3.yml")) - - @dummy = Dummy.new - end - should "return nil" do - assert_nil @dummy.avatar.expiring_url - end - end - - context "Using S3 for real, an attachment with S3 storage" do - setup do - rebuild_model :styles => { :thumb => "100x100", :square => "32x32#" }, - :storage => :s3, - :bucket => ENV["S3_BUCKET"], - :path => ":class/:attachment/:id/:style.:extension", - :s3_credentials => File.new(File.join(File.dirname(__FILE__), "..", "fixtures", "s3.yml")) - - Dummy.delete_all - @dummy = Dummy.new - end - - should "be extended by the S3 module" do - assert Dummy.new.avatar.is_a?(Paperclip::Storage::S3) - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy.avatar = @file - end - - teardown do - @file.close - @dummy.destroy - end - - should "still return a Tempfile when sent #to_file" do - assert_equal Paperclip::Tempfile, @dummy.avatar.to_file.class - end - - context "and saved" do - setup do - @dummy.save - end - - should "be on S3" do - assert true - end - - should "generate a tempfile with the right name" do - file = @dummy.avatar.to_file - assert_match /^original.*\.png$/, File.basename(file.path) - end - end - end - end - - context "An attachment that uses S3 for storage and has spaces in file name" do - setup do - rebuild_model :styles => { :thumb => "100x100", :square => "32x32#" }, - :storage => :s3, - :bucket => ENV["S3_BUCKET"], - :s3_credentials => File.new(File.join(File.dirname(__FILE__), "..", "fixtures", "s3.yml")) - - Dummy.delete_all - @dummy = Dummy.new - @dummy.avatar = File.new(fixture_file('spaced file.png'), 'rb') - @dummy.save - end - - should "return a replaced version for path" do - assert_match /.+\/spaced_file\.png/, @dummy.avatar.path - end - - should "return a replaced version for url" do - assert_match /.+\/spaced_file\.png/, @dummy.avatar.url - end - - should "be accessible" do - assert_success_response @dummy.avatar.url - end - - should "be destoryable" do - url = @dummy.avatar.url - @dummy.destroy - assert_not_found_response url - end - end - - context "An attachment that uses S3 for storage and uses AES256 encryption" do - setup do - rebuild_model :styles => { :thumb => "100x100", :square => "32x32#" }, - :storage => :s3, - :bucket => ENV["S3_BUCKET"], - :path => ":class/:attachment/:id/:style.:extension", - :s3_credentials => File.new(File.join(File.dirname(__FILE__), "..", "fixtures", "s3.yml")), - :s3_server_side_encryption => :aes256 - - Dummy.delete_all - @dummy = Dummy.new - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy.avatar = @file - end - - teardown do - @file.close - @dummy.destroy - end - - context "and saved" do - setup do - @dummy.save - end - - should "be encrypted on S3" do - assert @dummy.avatar.s3_object.server_side_encryption == :aes256 - end - end - end - end - end -end diff --git a/test/storage/s3_test.rb b/test/storage/s3_test.rb deleted file mode 100644 index 625fb709e..000000000 --- a/test/storage/s3_test.rb +++ /dev/null @@ -1,1073 +0,0 @@ -require './test/helper' -require 'aws' - -class S3Test < Test::Unit::TestCase - def rails_env(env) - silence_warnings do - Object.const_set(:Rails, stub('Rails', :env => env)) - end - end - - def setup - AWS.config(:access_key_id => "TESTKEY", :secret_access_key => "TESTSECRET", :stub_requests => true) - end - - def teardown - AWS.config(:access_key_id => nil, :secret_access_key => nil, :stub_requests => nil) - end - - context "Parsing S3 credentials" do - setup do - @proxy_settings = {:host => "127.0.0.1", :port => 8888, :user => "foo", :password => "bar"} - rebuild_model :storage => :s3, - :bucket => "testing", - :http_proxy => @proxy_settings, - :s3_credentials => {:not => :important} - - @dummy = Dummy.new - @avatar = @dummy.avatar - end - - should "get the correct credentials when RAILS_ENV is production" do - rails_env("production") - assert_equal({:key => "12345"}, - @avatar.parse_credentials('production' => {:key => '12345'}, - :development => {:key => "54321"})) - end - - should "get the correct credentials when RAILS_ENV is development" do - rails_env("development") - assert_equal({:key => "54321"}, - @avatar.parse_credentials('production' => {:key => '12345'}, - :development => {:key => "54321"})) - end - - should "return the argument if the key does not exist" do - rails_env("not really an env") - assert_equal({:test => "12345"}, @avatar.parse_credentials(:test => "12345")) - end - - should "support HTTP proxy settings" do - rails_env("development") - assert_equal(true, @avatar.using_http_proxy?) - assert_equal(@proxy_settings[:host], @avatar.http_proxy_host) - assert_equal(@proxy_settings[:port], @avatar.http_proxy_port) - assert_equal(@proxy_settings[:user], @avatar.http_proxy_user) - assert_equal(@proxy_settings[:password], @avatar.http_proxy_password) - end - - end - - context ":bucket option via :s3_credentials" do - - setup do - rebuild_model :storage => :s3, :s3_credentials => {:bucket => 'testing'} - @dummy = Dummy.new - end - - should "populate #bucket_name" do - assert_equal @dummy.avatar.bucket_name, 'testing' - end - - end - - context ":bucket option" do - - setup do - rebuild_model :storage => :s3, :bucket => "testing", :s3_credentials => {} - @dummy = Dummy.new - end - - should "populate #bucket_name" do - assert_equal @dummy.avatar.bucket_name, 'testing' - end - - end - - context "missing :bucket option" do - - setup do - rebuild_model :storage => :s3, - #:bucket => "testing", # intentionally left out - :http_proxy => @proxy_settings, - :s3_credentials => {:not => :important} - - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - - end - - should "raise an argument error" do - exception = assert_raise(ArgumentError) { @dummy.save } - assert_match /missing required :bucket option/, exception.message - end - - end - - context "" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => {}, - :bucket => "bucket", - :path => ":attachment/:basename.:extension", - :url => ":s3_path_url" - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - end - - should "return a url based on an S3 path" do - assert_match %r{^http://s3.amazonaws.com/bucket/avatars/stringio.txt}, @dummy.avatar.url - end - - should "use the correct bucket" do - assert_equal "bucket", @dummy.avatar.s3_bucket.name - end - - should "use the correct key" do - assert_equal "avatars/stringio.txt", @dummy.avatar.s3_object.key - end - - end - - context "An attachment that uses S3 for storage and has the style in the path" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :styles => { - :thumb => "80x80>" - }, - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - } - - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - @avatar = @dummy.avatar - end - - should "use an S3 object based on the correct path for the default style" do - assert_equal("avatars/original/stringio.txt", @dummy.avatar.s3_object.key) - end - - should "use an S3 object based on the correct path for the custom style" do - assert_equal("avatars/thumb/stringio.txt", @dummy.avatar.s3_object(:thumb).key) - end - end - - context "s3_host_name" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => {}, - :bucket => "bucket", - :path => ":attachment/:basename.:extension", - :s3_host_name => "s3-ap-northeast-1.amazonaws.com" - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - end - - should "return a url based on an :s3_host_name path" do - assert_match %r{^http://s3-ap-northeast-1.amazonaws.com/bucket/avatars/stringio.txt}, @dummy.avatar.url - end - - should "use the S3 bucket with the correct host name" do - assert_equal "s3-ap-northeast-1.amazonaws.com", @dummy.avatar.s3_bucket.config.s3_endpoint - end - end - - context "An attachment that uses S3 for storage and has styles that return different file types" do - setup do - rebuild_model :styles => { :large => ['500x500#', :jpg] }, - :storage => :s3, - :bucket => "bucket", - :path => ":attachment/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - } - - @dummy = Dummy.new - @dummy.avatar = File.new(fixture_file('5k.png'), 'rb') - end - - should "return a url containing the correct original file mime type" do - assert_match /.+\/5k.png/, @dummy.avatar.url - end - - should 'use the correct key for the original file mime type' do - assert_match /.+\/5k.png/, @dummy.avatar.s3_object.key - end - - should "return a url containing the correct processed file mime type" do - assert_match /.+\/5k.jpg/, @dummy.avatar.url(:large) - end - - should "use the correct key for the processed file mime type" do - assert_match /.+\/5k.jpg/, @dummy.avatar.s3_object(:large).key - end - end - - context "An attachment that uses S3 for storage and has spaces in file name" do - setup do - rebuild_model :styles => { :large => ['500x500#', :jpg] }, - :storage => :s3, - :bucket => "bucket", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - } - - @dummy = Dummy.new - @dummy.avatar = File.new(fixture_file('spaced file.png'), 'rb') - end - - should "return a replaced version for path" do - assert_match /.+\/spaced_file\.png/, @dummy.avatar.path - end - - should "return a replaced version for url" do - assert_match /.+\/spaced_file\.png/, @dummy.avatar.url - end - end - - context "An attachment that uses S3 for storage and has a question mark in file name" do - setup do - rebuild_model :styles => { :large => ['500x500#', :jpg] }, - :storage => :s3, - :bucket => "bucket", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - } - - file = StringIO.new(".") - file.original_filename = "question?mark.png" - @dummy = Dummy.new - @dummy.avatar = file - @dummy.save - end - - should "return a replaced version for path" do - assert_match /.+\/question_mark\.png/, @dummy.avatar.path - end - - should "return a replaced version for url" do - assert_match /.+\/question_mark\.png/, @dummy.avatar.url - end - end - - context "" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => {}, - :bucket => "bucket", - :path => ":attachment/:basename.:extension", - :url => ":s3_domain_url" - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - end - - should "return a url based on an S3 subdomain" do - assert_match %r{^http://bucket.s3.amazonaws.com/avatars/stringio.txt}, @dummy.avatar.url - end - end - - context "" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => { - :production => { :bucket => "prod_bucket" }, - :development => { :bucket => "dev_bucket" } - }, - :s3_host_alias => "something.something.com", - :path => ":attachment/:basename.:extension", - :url => ":s3_alias_url" - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - end - - should "return a url based on the host_alias" do - assert_match %r{^http://something.something.com/avatars/stringio.txt}, @dummy.avatar.url - end - end - - context "generating a url with a proc as the host alias" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => { :bucket => "prod_bucket" }, - :s3_host_alias => Proc.new{|atch| "cdn#{atch.instance.counter % 4}.example.com"}, - :path => ":attachment/:basename.:extension", - :url => ":s3_alias_url" - Dummy.class_eval do - def counter - @counter ||= 0 - @counter += 1 - @counter - end - end - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - end - - should "return a url based on the host_alias" do - assert_match %r{^http://cdn1.example.com/avatars/stringio.txt}, @dummy.avatar.url - assert_match %r{^http://cdn2.example.com/avatars/stringio.txt}, @dummy.avatar.url - end - - should "still return the bucket name" do - assert_equal "prod_bucket", @dummy.avatar.bucket_name - end - - end - - context "" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => {}, - :bucket => "bucket", - :path => ":attachment/:basename.:extension", - :url => ":asset_host" - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - end - - should "return a relative URL for Rails to calculate assets host" do - assert_match %r{^avatars/stringio\.txt}, @dummy.avatar.url - end - - should "always be rewound when returning from #to_file" do - assert_equal 0, @dummy.avatar.to_file.pos - @dummy.avatar.to_file.seek(10) - assert_equal 0, @dummy.avatar.to_file.pos - end - end - - context "Generating a secure url with an expiration" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => { - :production => { :bucket => "prod_bucket" }, - :development => { :bucket => "dev_bucket" } - }, - :s3_host_alias => "something.something.com", - :s3_permissions => "private", - :path => ":attachment/:basename.:extension", - :url => ":s3_alias_url" - - rails_env("production") - - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:url_for).with(:read, :expires => 3600, :secure => true) - - @dummy.avatar.expiring_url - end - - should "should succeed" do - assert true - end - end - - context "Generating a url with an expiration for each style" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => { - :production => { :bucket => "prod_bucket" }, - :development => { :bucket => "dev_bucket" } - }, - :s3_permissions => :private, - :s3_host_alias => "something.something.com", - :path => ":attachment/:style/:basename.:extension", - :url => ":s3_alias_url" - - rails_env("production") - - @dummy = Dummy.new - @dummy.avatar = StringIO.new(".") - end - - should "should generate a url for the thumb" do - object = stub - @dummy.avatar.stubs(:s3_object).with(:thumb).returns(object) - object.expects(:url_for).with(:read, :expires => 1800, :secure => true) - @dummy.avatar.expiring_url(1800, :thumb) - end - - should "should generate a url for the default style" do - object = stub - @dummy.avatar.stubs(:s3_object).with(:original).returns(object) - object.expects(:url_for).with(:read, :expires => 1800, :secure => true) - @dummy.avatar.expiring_url(1800) - end - end - - context "Parsing S3 credentials with a bucket in them" do - setup do - rebuild_model :storage => :s3, - :s3_credentials => { - :production => { :bucket => "prod_bucket" }, - :development => { :bucket => "dev_bucket" } - } - @dummy = Dummy.new - end - - should "get the right bucket in production" do - rails_env("production") - assert_equal "prod_bucket", @dummy.avatar.bucket_name - assert_equal "prod_bucket", @dummy.avatar.s3_bucket.name - end - - should "get the right bucket in development" do - rails_env("development") - assert_equal "dev_bucket", @dummy.avatar.bucket_name - assert_equal "dev_bucket", @dummy.avatar.s3_bucket.name - end - end - - context "Parsing S3 credentials with a s3_host_name in them" do - setup do - rebuild_model :storage => :s3, - :bucket => 'testing', - :s3_credentials => { - :production => { :s3_host_name => "s3-world-end.amazonaws.com" }, - :development => { :s3_host_name => "s3-ap-northeast-1.amazonaws.com" } - } - @dummy = Dummy.new - end - - should "get the right s3_host_name in production" do - rails_env("production") - assert_match %r{^s3-world-end.amazonaws.com}, @dummy.avatar.s3_host_name - assert_match %r{^s3-world-end.amazonaws.com}, @dummy.avatar.s3_bucket.config.s3_endpoint - end - - should "get the right s3_host_name in development" do - rails_env("development") - assert_match %r{^s3-ap-northeast-1.amazonaws.com}, @dummy.avatar.s3_host_name - assert_match %r{^s3-ap-northeast-1.amazonaws.com}, @dummy.avatar.s3_bucket.config.s3_endpoint - end - - should "get the right s3_host_name if the key does not exist" do - rails_env("test") - assert_match %r{^s3.amazonaws.com}, @dummy.avatar.s3_host_name - assert_match %r{^s3.amazonaws.com}, @dummy.avatar.s3_bucket.config.s3_endpoint - end - end - - context "An attachment with S3 storage" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - } - end - - should "be extended by the S3 module" do - assert Dummy.new.avatar.is_a?(Paperclip::Storage::S3) - end - - should "not be extended by the Filesystem module" do - assert ! Dummy.new.avatar.is_a?(Paperclip::Storage::Filesystem) - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - should "not get a bucket to get a URL" do - @dummy.avatar.expects(:s3).never - @dummy.avatar.expects(:s3_bucket).never - assert_match %r{^http://s3\.amazonaws\.com/testing/avatars/original/5k\.png}, @dummy.avatar.url - end - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read) - @dummy.save - end - - should "succeed" do - assert true - end - end - - should "delete tempfiles" do - File.stubs(:exist?).returns(true) - Paperclip::Tempfile.any_instance.expects(:close).at_least_once() - Paperclip::Tempfile.any_instance.expects(:unlink).at_least_once() - - @dummy.save! - end - - context "and saved without a bucket" do - setup do - AWS::S3::BucketCollection.any_instance.expects(:create).with("testing") - AWS::S3::S3Object.any_instance.stubs(:write). - raises(AWS::S3::Errors::NoSuchBucket.new(stub, - stub(:status => 404, - :body => ""))). - then.returns(nil) - @dummy.save - end - - should "succeed" do - assert true - end - end - - context "and remove" do - setup do - AWS::S3::S3Object.any_instance.stubs(:exists?).returns(true) - AWS::S3::S3Object.any_instance.stubs(:delete) - @dummy.destroy_attached_files - end - - should "succeed" do - assert true - end - end - - context 'that the file were missing' do - setup do - AWS::S3::S3Object.any_instance.stubs(:exists?).raises(AWS::Errors::Base) - end - - should 'return false on exists?' do - assert !@dummy.avatar.exists? - end - end - end - end - - context "An attachment with S3 storage and bucket defined as a Proc" do - setup do - rebuild_model :storage => :s3, - :bucket => lambda { |attachment| "bucket_#{attachment.instance.other}" }, - :s3_credentials => {:not => :important} - end - - should "get the right bucket name" do - assert "bucket_a", Dummy.new(:other => 'a').avatar.bucket_name - assert "bucket_a", Dummy.new(:other => 'a').avatar.s3_bucket.name - assert "bucket_b", Dummy.new(:other => 'b').avatar.bucket_name - assert "bucket_b", Dummy.new(:other => 'b').avatar.s3_bucket.name - end - end - - context "An attachment with S3 storage and specific s3 headers set" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_headers => {'Cache-Control' => 'max-age=31557600'} - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read, - :cache_control => 'max-age=31557600') - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "An attachment with S3 storage and metadata set using header names" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_headers => {'x-amz-meta-color' => 'red'} - end - - context "when assigned" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), '..', 'fixtures', '5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read, - :metadata => { "color" => "red" }) - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "An attachment with S3 storage and metadata set using the :s3_metadata option" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_metadata => { "color" => "red" } - end - - context "when assigned" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), '..', 'fixtures', '5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read, - :metadata => { "color" => "red" }) - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "An attachment with S3 storage and storage class set using the header name" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_headers => { "x-amz-storage-class" => "reduced_redundancy" } - end - - context "when assigned" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), '..', 'fixtures', '5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read, - :storage_class => "reduced_redundancy") - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "An attachment with S3 storage and using AES256 encryption" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_server_side_encryption => :aes256 - end - - context "when assigned" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), '..', 'fixtures', '5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read, - :server_side_encryption => :aes256) - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "An attachment with S3 storage and storage class set using the :storage_class option" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_storage_class => :reduced_redundancy - end - - context "when assigned" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), '..', 'fixtures', '5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read, - :storage_class => :reduced_redundancy) - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "with S3 credentials supplied as Pathname" do - setup do - ENV['S3_KEY'] = 'pathname_key' - ENV['S3_BUCKET'] = 'pathname_bucket' - ENV['S3_SECRET'] = 'pathname_secret' - - rails_env('test') - - rebuild_model :storage => :s3, - :s3_credentials => Pathname.new(fixture_file('s3.yml')) - - Dummy.delete_all - @dummy = Dummy.new - end - - should "parse the credentials" do - assert_equal 'pathname_bucket', @dummy.avatar.bucket_name - assert_equal 'pathname_key', @dummy.avatar.s3_bucket.config.access_key_id - assert_equal 'pathname_secret', @dummy.avatar.s3_bucket.config.secret_access_key - end - end - - context "with S3 credentials in a YAML file" do - setup do - ENV['S3_KEY'] = 'env_key' - ENV['S3_BUCKET'] = 'env_bucket' - ENV['S3_SECRET'] = 'env_secret' - - rails_env('test') - - rebuild_model :storage => :s3, - :s3_credentials => File.new(fixture_file('s3.yml')) - - Dummy.delete_all - - @dummy = Dummy.new - end - - should "run the file through ERB" do - assert_equal 'env_bucket', @dummy.avatar.bucket_name - assert_equal 'env_key', @dummy.avatar.s3_bucket.config.access_key_id - assert_equal 'env_secret', @dummy.avatar.s3_bucket.config.secret_access_key - end - end - - context "S3 Permissions" do - context "defaults to :public_read" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - } - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read) - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "string permissions set" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_permissions => :private - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - object = stub - @dummy.avatar.stubs(:s3_object).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :private) - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "hash permissions set" do - setup do - rebuild_model :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :styles => { - :thumb => "80x80>" - }, - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_permissions => { - :original => :private, - :thumb => :public_read - } - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - [:thumb, :original].each do |style| - object = stub - @dummy.avatar.stubs(:s3_object).with(style).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => style == :thumb ? :public_read : :private) - end - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end - - context "proc permission set" do - setup do - rebuild_model( - :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :styles => { - :thumb => "80x80>" - }, - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_permissions => lambda {|attachment, style| - attachment.instance.private_attachment? && style.to_sym != :thumb ? :private : :public_read - } - ) - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.stubs(:private_attachment? => true) - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - [:thumb, :original].each do |style| - object = stub - @dummy.avatar.stubs(:s3_object).with(style).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => style == :thumb ? :public_read : :private) - end - @dummy.save - end - - should "succeed" do - assert @dummy.avatar.url().include? "https://" - assert @dummy.avatar.url(:thumb).include? "http://" - end - end - end - - end - end - - context "An attachment with S3 storage and metadata set using a proc as headers" do - setup do - rebuild_model( - :storage => :s3, - :bucket => "testing", - :path => ":attachment/:style/:basename.:extension", - :styles => { - :thumb => "80x80>" - }, - :s3_credentials => { - 'access_key_id' => "12345", - 'secret_access_key' => "54321" - }, - :s3_headers => lambda {|attachment| - {'Content-Disposition' => "attachment; filename=\"#{attachment.name}\""} - } - ) - end - - context "when assigned" do - setup do - @file = File.new(fixture_file('5k.png'), 'rb') - @dummy = Dummy.new - @dummy.stubs(:name => 'Custom Avatar Name.png') - @dummy.avatar = @file - end - - teardown { @file.close } - - context "and saved" do - setup do - [:thumb, :original].each do |style| - object = stub - @dummy.avatar.stubs(:s3_object).with(style).returns(object) - object.expects(:write).with(anything, - :content_type => "image/png", - :acl => :public_read, - :content_disposition => 'attachment; filename="Custom Avatar Name.png"') - end - @dummy.save - end - - should "succeed" do - assert true - end - end - end - end -end diff --git a/test/style_test.rb b/test/style_test.rb deleted file mode 100644 index 998b10823..000000000 --- a/test/style_test.rb +++ /dev/null @@ -1,209 +0,0 @@ -# encoding: utf-8 -require './test/helper' - -class StyleTest < Test::Unit::TestCase - - context "A style rule" do - setup do - @attachment = attachment :path => ":basename.:extension", - :styles => { :foo => {:geometry => "100x100#", :format => :png} }, - :whiny => true - @style = @attachment.styles[:foo] - end - - should "be held as a Style object" do - assert_kind_of Paperclip::Style, @style - end - - should "get processors from the attachment definition" do - assert_equal [:thumbnail], @style.processors - end - - should "have the right geometry" do - assert_equal "100x100#", @style.geometry - end - - should "be whiny if the attachment is" do - assert @style.whiny? - end - - should "respond to hash notation" do - assert_equal [:thumbnail], @style[:processors] - assert_equal "100x100#", @style[:geometry] - end - end - - context "A style rule with properties supplied as procs" do - setup do - @attachment = attachment :path => ":basename.:extension", - :whiny_thumbnails => true, - :processors => lambda {|a| [:test]}, - :styles => { - :foo => lambda{|a| "300x300#"}, - :bar => { - :geometry => lambda{|a| "300x300#"}, - :convert_options => lambda{|a| "-do_stuff"}, - :source_file_options => lambda{|a| "-do_extra_stuff"} - } - } - end - - should "call procs when they are needed" do - assert_equal "300x300#", @attachment.styles[:foo].geometry - assert_equal "300x300#", @attachment.styles[:bar].geometry - assert_equal [:test], @attachment.styles[:foo].processors - assert_equal [:test], @attachment.styles[:bar].processors - assert_equal "-do_stuff", @attachment.styles[:bar].convert_options - assert_equal "-do_extra_stuff", @attachment.styles[:bar].source_file_options - end - end - - context "An attachment with style rules in various forms" do - setup do - styles = ActiveSupport::OrderedHash.new - styles[:aslist] = ["100x100", :png] - styles[:ashash] = {:geometry => "100x100", :format => :png} - styles[:asstring] = "100x100" - @attachment = attachment :path => ":basename.:extension", - :styles => styles - end - should "have the right number of styles" do - assert_kind_of Hash, @attachment.styles - assert_equal 3, @attachment.styles.size - end - - should "have styles as Style objects" do - [:aslist, :ashash, :aslist].each do |s| - assert_kind_of Paperclip::Style, @attachment.styles[s] - end - end - - should "have the right geometries" do - [:aslist, :ashash, :aslist].each do |s| - assert_equal @attachment.styles[s].geometry, "100x100" - end - end - - should "have the right formats" do - assert_equal @attachment.styles[:aslist].format, :png - assert_equal @attachment.styles[:ashash].format, :png - assert_nil @attachment.styles[:asstring].format - end - - should "retain order" do - assert_equal [:aslist, :ashash, :asstring], @attachment.styles.keys - end - end - - context "An attachment with :convert_options" do - setup do - @attachment = attachment :path => ":basename.:extension", - :styles => {:thumb => "100x100", :large => "400x400"}, - :convert_options => {:all => "-do_stuff", :thumb => "-thumbnailize"} - @style = @attachment.styles[:thumb] - @file = StringIO.new("...") - @file.stubs(:original_filename).returns("file.jpg") - end - - before_should "not have called extra_options_for(:thumb/:large) on initialization" do - @attachment.expects(:extra_options_for).never - end - - should "call extra_options_for(:thumb/:large) when convert options are requested" do - @attachment.expects(:extra_options_for).with(:thumb) - @attachment.styles[:thumb].convert_options - end - end - - context "An attachment with :source_file_options" do - setup do - @attachment = attachment :path => ":basename.:extension", - :styles => {:thumb => "100x100", :large => "400x400"}, - :source_file_options => {:all => "-density 400", :thumb => "-depth 8"} - @style = @attachment.styles[:thumb] - @file = StringIO.new("...") - @file.stubs(:original_filename).returns("file.jpg") - end - - before_should "not have called extra_source_file_options_for(:thumb/:large) on initialization" do - @attachment.expects(:extra_source_file_options_for).never - end - - should "call extra_options_for(:thumb/:large) when convert options are requested" do - @attachment.expects(:extra_source_file_options_for).with(:thumb) - @attachment.styles[:thumb].source_file_options - end - end - - context "A style rule with its own :processors" do - setup do - @attachment = attachment :path => ":basename.:extension", - :styles => { - :foo => { - :geometry => "100x100#", - :format => :png, - :processors => [:test] - } - }, - :processors => [:thumbnail] - @style = @attachment.styles[:foo] - end - - should "not get processors from the attachment" do - @attachment.expects(:processors).never - assert_not_equal [:thumbnail], @style.processors - end - - should "report its own processors" do - assert_equal [:test], @style.processors - end - - end - - context "A style rule with :processors supplied as procs" do - setup do - @attachment = attachment :path => ":basename.:extension", - :styles => { - :foo => { - :geometry => "100x100#", - :format => :png, - :processors => lambda{|a| [:test]} - } - }, - :processors => [:thumbnail] - end - - should "defer processing of procs until they are needed" do - assert_kind_of Proc, @attachment.styles[:foo].instance_variable_get("@processors") - end - - should "call procs when they are needed" do - assert_equal [:test], @attachment.styles[:foo].processors - end - end - - context "An attachment with :convert_options and :source_file_options in :styles" do - setup do - @attachment = attachment :path => ":basename.:extension", - :styles => { - :thumb => "100x100", - :large => {:geometry => "400x400", - :convert_options => "-do_stuff", - :source_file_options => "-do_extra_stuff" - } - } - @file = StringIO.new("...") - @file.stubs(:original_filename).returns("file.jpg") - end - - should "have empty options for :thumb style" do - assert_equal "", @attachment.styles[:thumb].processor_options[:convert_options] - assert_equal "", @attachment.styles[:thumb].processor_options[:source_file_options] - end - - should "have the right options for :large style" do - assert_equal "-do_stuff", @attachment.styles[:large].processor_options[:convert_options] - assert_equal "-do_extra_stuff", @attachment.styles[:large].processor_options[:source_file_options] - end - end -end diff --git a/test/support/mock_model.rb b/test/support/mock_model.rb deleted file mode 100644 index a6e7c12d6..000000000 --- a/test/support/mock_model.rb +++ /dev/null @@ -1,2 +0,0 @@ -class MockModel -end diff --git a/test/thumbnail_test.rb b/test/thumbnail_test.rb deleted file mode 100644 index 0050f3794..000000000 --- a/test/thumbnail_test.rb +++ /dev/null @@ -1,392 +0,0 @@ -require './test/helper' - -class ThumbnailTest < Test::Unit::TestCase - - context "A Paperclip Tempfile" do - setup do - @tempfile = Paperclip::Tempfile.new(["file", ".jpg"]) - end - - should "have its path contain a real extension" do - assert_equal ".jpg", File.extname(@tempfile.path) - end - - should "be a real Tempfile" do - assert @tempfile.is_a?(::Tempfile) - end - end - - context "Another Paperclip Tempfile" do - setup do - @tempfile = Paperclip::Tempfile.new("file") - end - - should "not have an extension if not given one" do - assert_equal "", File.extname(@tempfile.path) - end - - should "still be a real Tempfile" do - assert @tempfile.is_a?(::Tempfile) - end - end - - context "An image" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "5k.png"), 'rb') - end - - teardown { @file.close } - - [["600x600>", "434x66"], - ["400x400>", "400x61"], - ["32x32<", "434x66"] - ].each do |args| - context "being thumbnailed with a geometry of #{args[0]}" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, :geometry => args[0]) - end - - should "start with dimensions of 434x66" do - cmd = %Q[identify -format "%wx%h" "#{@file.path}"] - assert_equal "434x66", `#{cmd}`.chomp - end - - should "report the correct target geometry" do - assert_equal args[0], @thumb.target_geometry.to_s - end - - context "when made" do - setup do - @thumb_result = @thumb.make - end - - should "be the size we expect it to be" do - cmd = %Q[identify -format "%wx%h" "#{@thumb_result.path}"] - assert_equal args[1], `#{cmd}`.chomp - end - end - end - end - - context "being thumbnailed at 100x50 with cropping" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, :geometry => "100x50#") - end - - should "let us know when a command isn't found versus a processing error" do - old_path = ENV['PATH'] - begin - ENV['PATH'] = '' - assert_raises(Paperclip::CommandNotFoundError) do - @thumb.make - end - ensure - ENV['PATH'] = old_path - end - end - - should "report its correct current and target geometries" do - assert_equal "100x50#", @thumb.target_geometry.to_s - assert_equal "434x66", @thumb.current_geometry.to_s - end - - should "report its correct format" do - assert_nil @thumb.format - end - - should "have whiny turned on by default" do - assert @thumb.whiny - end - - should "have convert_options set to nil by default" do - assert_equal nil, @thumb.convert_options - end - - should "have source_file_options set to nil by default" do - assert_equal nil, @thumb.source_file_options - end - - should "send the right command to convert when sent #make" do - Paperclip.expects(:run).with do |*arg| - arg[0] == 'convert' && - arg[1] == ':source -resize "x50" -crop "100x50+114+0" +repage :dest' && - arg[2][:source] == "#{File.expand_path(@thumb.file.path)}[0]" - end - @thumb.make - end - - should "create the thumbnail when sent #make" do - dst = @thumb.make - assert_match /100x50/, `identify "#{dst.path}"` - end - end - - context "being thumbnailed with source file options set" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, - :geometry => "100x50#", - :source_file_options => "-strip") - end - - should "have source_file_options value set" do - assert_equal ["-strip"], @thumb.source_file_options - end - - should "send the right command to convert when sent #make" do - Paperclip.expects(:run).with do |*arg| - arg[0] == 'convert' && - arg[1] == '-strip :source -resize "x50" -crop "100x50+114+0" +repage :dest' && - arg[2][:source] == "#{File.expand_path(@thumb.file.path)}[0]" - end - @thumb.make - end - - should "create the thumbnail when sent #make" do - dst = @thumb.make - assert_match /100x50/, `identify "#{dst.path}"` - end - - context "redefined to have bad source_file_options setting" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, - :geometry => "100x50#", - :source_file_options => "-this-aint-no-option") - end - - should "error when trying to create the thumbnail" do - assert_raises(Paperclip::PaperclipError) do - @thumb.make - end - end - end - end - - context "being thumbnailed with convert options set" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, - :geometry => "100x50#", - :convert_options => "-strip -depth 8") - end - - should "have convert_options value set" do - assert_equal %w"-strip -depth 8", @thumb.convert_options - end - - should "send the right command to convert when sent #make" do - Paperclip.expects(:run).with do |*arg| - arg[0] == 'convert' && - arg[1] == ':source -resize "x50" -crop "100x50+114+0" +repage -strip -depth 8 :dest' && - arg[2][:source] == "#{File.expand_path(@thumb.file.path)}[0]" - end - @thumb.make - end - - should "create the thumbnail when sent #make" do - dst = @thumb.make - assert_match /100x50/, `identify "#{dst.path}"` - end - - context "redefined to have bad convert_options setting" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, - :geometry => "100x50#", - :convert_options => "-this-aint-no-option") - end - - should "error when trying to create the thumbnail" do - assert_raises(Paperclip::PaperclipError) do - @thumb.make - end - end - - should "let us know when a command isn't found versus a processing error" do - old_path = ENV['PATH'] - begin - ENV['PATH'] = '' - assert_raises(Paperclip::CommandNotFoundError) do - @thumb.make - end - ensure - ENV['PATH'] = old_path - end - end - end - end - - context "being thumbnailed with a blank geometry string" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, - :geometry => "", - :convert_options => "-gravity center -crop \"300x300+0-0\"") - end - - should "not get resized by default" do - assert !@thumb.transformation_command.include?("-resize") - end - end - - context "passing a custom file geometry parser" do - teardown do - self.class.send(:remove_const, :GeoParser) - end - - should "produce the appropriate transformation_command" do - GeoParser = Class.new do - def self.from_file(file) - new - end - - def transformation_to(target, should_crop) - ["SCALE", "CROP"] - end - end - - thumb = Paperclip::Thumbnail.new(@file, :geometry => '50x50', :file_geometry_parser => GeoParser) - - transformation_command = thumb.transformation_command - - assert transformation_command.include?('-crop'), - %{expected #{transformation_command.inspect} to include '-crop'} - assert transformation_command.include?('"CROP"'), - %{expected #{transformation_command.inspect} to include '"CROP"'} - assert transformation_command.include?('-resize'), - %{expected #{transformation_command.inspect} to include '-resize'} - assert transformation_command.include?('"SCALE"'), - %{expected #{transformation_command.inspect} to include '"SCALE"'} - end - end - - context "passing a custom geometry string parser" do - teardown do - self.class.send(:remove_const, :GeoParser) - end - - should "produce the appropriate transformation_command" do - GeoParser = Class.new do - def self.parse(s) - new - end - - def to_s - "151x167" - end - end - - thumb = Paperclip::Thumbnail.new(@file, :geometry => '50x50', :string_geometry_parser => GeoParser) - - transformation_command = thumb.transformation_command - - assert transformation_command.include?('"151x167"'), - %{expected #{transformation_command.inspect} to include '151x167'} - end - end - end - - context "A multipage PDF" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "twopage.pdf"), 'rb') - end - - teardown { @file.close } - - should "start with two pages with dimensions 612x792" do - cmd = %Q[identify -format "%wx%h" "#{@file.path}"] - assert_equal "612x792"*2, `#{cmd}`.chomp - end - - context "being thumbnailed at 100x100 with cropping" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, :geometry => "100x100#", :format => :png) - end - - should "report its correct current and target geometries" do - assert_equal "100x100#", @thumb.target_geometry.to_s - assert_equal "612x792", @thumb.current_geometry.to_s - end - - should "report its correct format" do - assert_equal :png, @thumb.format - end - - should "create the thumbnail when sent #make" do - dst = @thumb.make - assert_match /100x100/, `identify "#{dst.path}"` - end - end - end - - context "An animated gif" do - setup do - @file = File.new(File.join(File.dirname(__FILE__), "fixtures", "animated.gif"), 'rb') - end - - teardown { @file.close } - - should "start with 12 frames with size 100x100" do - cmd = %Q[identify -format "%wx%h" "#{@file.path}"] - assert_equal "100x100"*12, `#{cmd}`.chomp - end - - context "with static output" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, :geometry => "50x50", :format => :jpg) - end - - should "create the single frame thumbnail when sent #make" do - dst = @thumb.make - cmd = %Q[identify -format "%wx%h" "#{dst.path}"] - assert_equal "50x50", `#{cmd}`.chomp - end - end - - context "with animated output format" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, :geometry => "50x50", :format => :gif) - end - - should "create the 12 frames thumbnail when sent #make" do - dst = @thumb.make - cmd = %Q[identify -format "%wx%h" "#{dst.path}"] - assert_equal "50x50"*12, `#{cmd}`.chomp - end - - should "use the -coalesce option" do - assert_equal @thumb.transformation_command.first, "-coalesce" - end - end - - context "with omitted output format" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, :geometry => "50x50") - end - - should "create the 12 frames thumbnail when sent #make" do - dst = @thumb.make - cmd = %Q[identify -format "%wx%h" "#{dst.path}"] - assert_equal "50x50"*12, `#{cmd}`.chomp - end - - should "use the -coalesce option" do - assert_equal @thumb.transformation_command.first, "-coalesce" - end - end - - context "with animated option set to false" do - setup do - @thumb = Paperclip::Thumbnail.new(@file, :geometry => "50x50", :animated => false) - end - - should "output the gif format" do - dst = @thumb.make - cmd = %Q[identify "#{dst.path}"] - assert_match /GIF/, `#{cmd}`.chomp - end - - should "create the single frame thumbnail when sent #make" do - dst = @thumb.make - cmd = %Q[identify -format "%wx%h" "#{dst.path}"] - assert_equal "50x50", `#{cmd}`.chomp - end - end - end -end diff --git a/test/upfile_test.rb b/test/upfile_test.rb deleted file mode 100644 index 45a1c21b8..000000000 --- a/test/upfile_test.rb +++ /dev/null @@ -1,53 +0,0 @@ -require './test/helper' - -class UpfileTest < Test::Unit::TestCase - { %w(jpg jpe jpeg) => 'image/jpeg', - %w(tif tiff) => 'image/tiff', - %w(png) => 'image/png', - %w(gif) => 'image/gif', - %w(bmp) => 'image/bmp', - %w(svg) => 'image/svg+xml', - %w(txt) => 'text/plain', - %w(htm html) => 'text/html', - %w(csv) => 'text/csv', - %w(xml) => 'application/xml', - %w(css) => 'text/css', - %w(js) => 'application/javascript', - %w(foo) => 'application/x-foo' - }.each do |extensions, content_type| - extensions.each do |extension| - should "return a content_type of #{content_type} for a file with extension .#{extension}" do - file = stub('file', :path => "basename.#{extension}") - class << file - include Paperclip::Upfile - end - - assert_equal content_type, file.content_type - end - end - end - - should "return a content_type of text/plain on a real file whose content_type is determined with the file command" do - file = File.new(File.join(File.dirname(__FILE__), "..", "LICENSE")) - class << file - include Paperclip::Upfile - end - assert_equal 'text/plain', file.content_type - end - - { '5k.png' => 'image/png', - 'animated.gif' => 'image/gif', - 'text.txt' => 'text/plain', - 'twopage.pdf' => 'application/pdf' - }.each do |filename, content_type| - should "return a content type of #{content_type} from a file command for file #{filename}" do - file = File.new(File.join(File.dirname(__FILE__), "fixtures", filename)) - class << file - include Paperclip::Upfile - end - - assert_equal content_type, file.type_from_file_command - end - end - -end diff --git a/test/url_generator_test.rb b/test/url_generator_test.rb deleted file mode 100644 index 661155219..000000000 --- a/test/url_generator_test.rb +++ /dev/null @@ -1,187 +0,0 @@ -# encoding: utf-8 -require './test/helper' -require 'paperclip/url_generator' - -class UrlGeneratorTest < Test::Unit::TestCase - should "use the given interpolator" do - expected = "the expected result" - mock_attachment = MockAttachment.new - mock_interpolator = MockInterpolator.new(:result => expected) - - url_generator = Paperclip::UrlGenerator.new(mock_attachment, - { :interpolator => mock_interpolator }) - result = url_generator.for(:style_name, {}) - - assert_equal expected, result - assert mock_interpolator.has_interpolated_attachment?(mock_attachment) - assert mock_interpolator.has_interpolated_style_name?(:style_name) - end - - should "use the default URL when no file is assigned" do - mock_attachment = MockAttachment.new - mock_interpolator = MockInterpolator.new - default_url = "the default url" - options = { :interpolator => mock_interpolator, :default_url => default_url} - - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - url_generator.for(:style_name, {}) - - assert mock_interpolator.has_interpolated_pattern?(default_url), - "expected the interpolator to be passed #{default_url.inspect} but it wasn't" - end - - should "execute the default URL lambda when no file is assigned" do - mock_attachment = MockAttachment.new - mock_interpolator = MockInterpolator.new - default_url = lambda {|attachment| "the #{attachment.class.name} default url" } - options = { :interpolator => mock_interpolator, :default_url => default_url} - - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - url_generator.for(:style_name, {}) - - assert mock_interpolator.has_interpolated_pattern?("the MockAttachment default url"), - %{expected the interpolator to be passed "the MockAttachment default url", but it wasn't} - end - - should "execute the method named by the symbol as the default URL when no file is assigned" do - mock_model = MockModel.new - mock_attachment = MockAttachment.new(:model => mock_model) - mock_interpolator = MockInterpolator.new - default_url = :to_s - options = { :interpolator => mock_interpolator, :default_url => default_url} - - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - url_generator.for(:style_name, {}) - - assert mock_interpolator.has_interpolated_pattern?(mock_model.to_s), - %{expected the interpolator to be passed #{mock_model.to_s}, but it wasn't} - end - - should "URL-escape spaces if asked to" do - expected = "the expected result" - mock_attachment = MockAttachment.new - mock_interpolator = MockInterpolator.new(:result => expected) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:escape => true}) - - assert_equal "the%20expected%20result", result - end - - should "escape the result of the interpolator using a method on the object, if asked to escape" do - expected = Class.new do - def escape - "the escaped result" - end - end.new - mock_attachment = MockAttachment.new - mock_interpolator = MockInterpolator.new(:result => expected) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:escape => true}) - - assert_equal "the escaped result", result - end - - should "leave spaces unescaped as asked to" do - expected = "the expected result" - mock_attachment = MockAttachment.new - mock_interpolator = MockInterpolator.new(:result => expected) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:escape => false}) - - assert_equal "the expected result", result - end - - should "default to leaving spaces unescaped" do - expected = "the expected result" - mock_attachment = MockAttachment.new - mock_interpolator = MockInterpolator.new(:result => expected) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {}) - - assert_equal "the expected result", result - end - - should "produce URLs without the updated_at value when the object does not respond to updated_at" do - expected = "the expected result" - mock_interpolator = MockInterpolator.new(:result => expected) - mock_attachment = MockAttachment.new(:responds_to_updated_at => false) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:timestamp => true}) - - assert_equal expected, result - end - - should "produce URLs without the updated_at value when the updated_at value is nil" do - expected = "the expected result" - mock_interpolator = MockInterpolator.new(:result => expected) - mock_attachment = MockAttachment.new(:responds_to_updated_at => true, :updated_at => nil) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:timestamp => true}) - - assert_equal expected, result - end - - should "produce URLs with the updated_at when it exists" do - expected = "the expected result" - updated_at = 1231231234 - mock_interpolator = MockInterpolator.new(:result => expected) - mock_attachment = MockAttachment.new(:updated_at => updated_at) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:timestamp => true}) - - assert_equal "#{expected}?#{updated_at}", result - end - - should "produce URLs with the updated_at when it exists, separated with a & if a ? follow by = already exists" do - expected = "the?expected=result" - updated_at = 1231231234 - mock_interpolator = MockInterpolator.new(:result => expected) - mock_attachment = MockAttachment.new(:updated_at => updated_at) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:timestamp => true}) - - assert_equal "#{expected}&#{updated_at}", result - end - - should "produce URLs without the updated_at when told to do as much" do - expected = "the expected result" - updated_at = 1231231234 - mock_interpolator = MockInterpolator.new(:result => expected) - mock_attachment = MockAttachment.new(:updated_at => updated_at) - options = { :interpolator => mock_interpolator } - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - - result = url_generator.for(:style_name, {:timestamp => false}) - - assert_equal expected, result - end - - should "produce the correct URL when the instance has a file name" do - expected = "the expected result" - mock_attachment = MockAttachment.new(:original_filename => 'exists') - mock_interpolator = MockInterpolator.new - options = { :interpolator => mock_interpolator, :url => expected} - - url_generator = Paperclip::UrlGenerator.new(mock_attachment, options) - url_generator.for(:style_name, {}) - - assert mock_interpolator.has_interpolated_pattern?(expected), - "expected the interpolator to be passed #{expected.inspect} but it wasn't" - end -end