This commit is contained in:
aggie 2026-03-11 22:22:11 +00:00
commit 5fba9fe725
2468 changed files with 284429 additions and 0 deletions

12
.codeclimate.yml Normal file
View file

@ -0,0 +1,12 @@
exclude_paths:
- "bin/"
- "config/"
- "db/"
- "factories/"
- "features/"
- "public/**/*.min.js"
- "public/javascripts/bootstrap/"
- "public/javascripts/tinymce/"
- "script/"
- "spec/"
- "test/"

1
.codecov.yml Normal file
View file

@ -0,0 +1 @@
comment: false

45
.erb-lint.yml Normal file
View file

@ -0,0 +1,45 @@
---
EnableDefaultLinters: true
linters:
AllowedScriptType:
allowed_types:
- "text/javascript"
- "speculationrules"
DeprecatedClasses:
enabled: true
rule_set:
- deprecated: ["bookmarks", "collections", "readings", "works"]
suggestion: "Avoid the plural form of these classes."
RequireInputAutocomplete:
enabled: false
Rubocop:
enabled: true
rubocop_config:
inherit_from:
- .rubocop.yml
Layout/ArgumentAlignment:
EnforcedStyle: with_fixed_indentation
Layout/InitialIndentation:
Enabled: false
Layout/LineLength:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Layout/TrailingWhitespace:
Enabled: false
Naming/FileName:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Lint/UselessAssignment:
Enabled: false
# Workaround for RuboCop 0.72 and later
# https://github.com/Shopify/erb-lint/issues/130
Rails:
Enabled: true
Rails/OutputSafety:
Enabled: false
SelfClosingTag:
enabled: true
# XHTML style
enforced_style: always

6
.gitattributes vendored Normal file
View file

@ -0,0 +1,6 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
# Bash scripts needs to have LF line endings, even on Windows
*.sh text eol=lf
# /usr/local/bin/ruby: warning: shebang line ending with \r may cause problems
/bin/* text eol=lf

108
.gitignore vendored Normal file
View file

@ -0,0 +1,108 @@
*.swp
.DS_Store
.bundle
.idea
stdout
*~
aria_log_control
.vscode/
docker-compose.yml
# /
/*.tmproj
/sphinx
/.DAV
/rerun.txt
/capybara-*.html
/.dropbox
/.dropbox.attr
/.rspec
REVISION
aria_log.00000001
# /config/
/config/brakeman.ignore
/config/docker/database.yml
/config/database.yml
/config/local.yml
/config/backup.yml
/config/*.sphinx.conf
/config/s3.yml
/config/sphinx.yml
/config/unicorn.rb
/config/newrelic.yml
/config/redis.yml
/config/redis-cucumber.conf
/config/skins.dump
config/local.yml.save
ibdata1
ib_buffer_pool
ib_logfile0
bunder_gems/
bundler_gems/
otwarchive_production/
performance_schema/
/bunder_gems
/bunder_gems/
/bunder_gems/*
/bundler_gems
/bundler_gems/
/bundler_gems/*
ibtmp1
/performance_schema
/performance_schema/*
/performance-schema
/performance_schema/
/mysql/*
/otwarchive_production/*
/db/*
/elastic-data/*
/elastic-data
/elastic-data/
/elastic/*
# /config/environments/
/config/environments/development.rb
# /db/
/db/*.sqlite3
/db/backup
/db/sphinx
/db/seed
# /log/
/log/*
# /public/
/public/downloads
/public/stylesheets/cached_for_screen.css
/public/stylesheets/skins
public/system/development
/public/system/skins
public/system/test
/public/system/work_skins
/public/tags
# /public/images/
/public/images/Thumbs.db
# /tmp/
/tmp/*
# ActiveRecord storage path
storage/
# /vendor/
/vendor/gems
# /vendor/gems/
/vendor/gems/*
# /vendor/plugins/
features/cassette_library/
spec/results.html
spec/results
features_report.html
coverage
redis-cucumber-dump.rdb
.rspec

17
.gitpod.yml Normal file
View file

@ -0,0 +1,17 @@
tasks:
- init: ./script/docker/init.sh
command: docker compose up -d web
ports:
- port: 3000
onOpen: open-browser
- port: 3306
onOpen: ignore
- port: 6379
onOpen: ignore
- port: 9200
onOpen: ignore
- port: 9300
onOpen: ignore
- port: 9400
onOpen: ignore

9
.hound.yml Normal file
View file

@ -0,0 +1,9 @@
# Available linter versions:
# http://help.houndci.com/en/articles/2461415-supported-linters
jshint:
config_file: .jshintrc
ignore_file: .jshintignore
rubocop:
enabled: false

4
.jshintignore Normal file
View file

@ -0,0 +1,4 @@
coverage
public/**/*.min.js
public/javascripts/bootstrap
public/javascripts/tinymce

1
.jshintrc Normal file
View file

@ -0,0 +1 @@
{}

164
.phrase.yml Normal file
View file

@ -0,0 +1,164 @@
phrase:
project_id: a3667b8095533c2b3f8d3ac946bb642f
file_format: yml
push:
sources:
# controllers
- file: ./config/locales/controllers/en.yml
params:
update_translations: true
# devise
- file: ./config/locales/devise/en.yml
params:
update_translations: true
# mailers
- file: ./config/locales/mailers/en.yml
params:
update_translations: true
# models
- file: ./config/locales/models/en.yml
params:
update_translations: true
# validators
- file: ./config/locales/validators/en.yml
params:
update_translations: true
# views
- file: ./config/locales/views/en.yml
params:
update_translations: true
pull:
targets:
- file: ./config/locales/phrase-exports/af.yml
params:
locale_id: af
- file: ./config/locales/phrase-exports/ar.yml
params:
locale_id: ar
- file: ./config/locales/phrase-exports/bg.yml
params:
locale_id: bg
- file: ./config/locales/phrase-exports/bn.yml
params:
locale_id: bn
- file: ./config/locales/phrase-exports/ca.yml
params:
locale_id: ca
- file: ./config/locales/phrase-exports/cs.yml
params:
locale_id: cs
- file: ./config/locales/phrase-exports/cy.yml
params:
locale_id: cy
- file: ./config/locales/phrase-exports/da.yml
params:
locale_id: da
- file: ./config/locales/phrase-exports/de.yml
params:
locale_id: de
- file: ./config/locales/phrase-exports/el.yml
params:
locale_id: el
- file: ./config/locales/phrase-exports/es.yml
params:
locale_id: es
- file: ./config/locales/phrase-exports/fa.yml
params:
locale_id: fa
- file: ./config/locales/phrase-exports/fi.yml
params:
locale_id: fi
- file: ./config/locales/phrase-exports/fil.yml
params:
locale_id: fil
- file: ./config/locales/phrase-exports/fr.yml
params:
locale_id: fr
- file: ./config/locales/phrase-exports/he.yml
params:
locale_id: he
- file: ./config/locales/phrase-exports/hi.yml
params:
locale_id: hi
- file: ./config/locales/phrase-exports/hr.yml
params:
locale_id: hr
- file: ./config/locales/phrase-exports/hu.yml
params:
locale_id: hu
- file: ./config/locales/phrase-exports/id.yml
params:
locale_id: id
- file: ./config/locales/phrase-exports/it.yml
params:
locale_id: it
- file: ./config/locales/phrase-exports/ja.yml
params:
locale_id: ja
- file: ./config/locales/phrase-exports/ko.yml
params:
locale_id: ko
- file: ./config/locales/phrase-exports/lt.yml
params:
locale_id: lt
- file: ./config/locales/phrase-exports/lv.yml
params:
locale_id: lv
- file: ./config/locales/phrase-exports/mk.yml
params:
locale_id: mk
- file: ./config/locales/phrase-exports/mr.yml
params:
locale_id: mr
- file: ./config/locales/phrase-exports/ms.yml
params:
locale_id: ms
- file: ./config/locales/phrase-exports/nb.yml
params:
locale_id: nb
- file: ./config/locales/phrase-exports/nl.yml
params:
locale_id: nl
- file: ./config/locales/phrase-exports/pl.yml
params:
locale_id: pl
- file: ./config/locales/phrase-exports/pt-BR.yml
params:
locale_id: pt-BR
- file: ./config/locales/phrase-exports/pt-PT.yml
params:
locale_id: pt-PT
- file: ./config/locales/phrase-exports/ro.yml
params:
locale_id: ro
- file: ./config/locales/phrase-exports/ru.yml
params:
locale_id: ru
- file: ./config/locales/phrase-exports/scr.yml
params:
locale_id: scr
- file: ./config/locales/phrase-exports/sk.yml
params:
locale_id: sk
- file: ./config/locales/phrase-exports/sl.yml
params:
locale_id: sl
- file: ./config/locales/phrase-exports/sv.yml
params:
locale_id: sv
- file: ./config/locales/phrase-exports/th.yml
params:
locale_id: th
- file: ./config/locales/phrase-exports/tr.yml
params:
locale_id: tr
- file: ./config/locales/phrase-exports/uk.yml
params:
locale_id: uk
- file: ./config/locales/phrase-exports/vi.yml
params:
locale_id: vi
- file: ./config/locales/phrase-exports/zh-CN.yml
params:
locale_id: zh-CN

310
.rubocop.yml Normal file
View file

@ -0,0 +1,310 @@
# Options available at https://github.com/bbatsov/rubocop/blob/master/config/default.yml
require:
- rubocop-rails
- rubocop-rspec
- ./rubocop/rubocop
inherit_mode:
merge:
- Exclude
AllCops:
NewCops: enable
TargetRubyVersion: 3.1
Bundler/OrderedGems:
Enabled: false
I18n/DeprecatedTranslationKey:
Rules:
name_with_colon: "Prefer `name` with `mailer.general.metadata_label_indicator` over `name_with_colon`"
Layout/DotPosition:
EnforcedStyle: leading
Layout/EmptyLinesAroundAttributeAccessor:
Enabled: false
Layout/FirstArrayElementIndentation:
EnforcedStyle: consistent
Layout/LineLength:
Enabled: false
Layout/MultilineMethodCallIndentation:
EnforcedStyle: indented
Layout/SingleLineBlockChain:
Enabled: true
Layout/TrailingWhitespace:
Enabled: false
Lint/AmbiguousBlockAssociation:
Exclude:
# Exception for specs where we use change matchers:
# https://github.com/rubocop-hq/rubocop/issues/4222
- 'features/step_definitions/**/*.rb'
- 'spec/**/*.rb'
Lint/AmbiguousRegexpLiteral:
Enabled: false
Lint/RedundantSafeNavigation:
Exclude:
# Take a better safe than sorry approach to safe navigation in admin
# policies.
- 'app/policies/*.rb'
Metrics/AbcSize:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/ClassLength:
Enabled: false
Metrics/CyclomaticComplexity:
Enabled: false
Metrics/MethodLength:
Enabled: false
Metrics/ModuleLength:
Enabled: false
Metrics/ParameterLists:
CountKeywordArgs: false
Metrics/PerceivedComplexity:
Enabled: false
Migration/LargeTableSchemaUpdate:
Tables:
- abuse_reports
- active_storage_attachments
- active_storage_blobs
- active_storage_variant_records
- admin_activities
- audits
- blocks
- bookmarks
- chapters
- comments
- common_taggings
- collection_items
- collection_participants
- collection_preferences
- collection_profiles
- collections
- creatorships
- external_works
- favorite_tags
- feedbacks
- filter_counts
- filter_taggings
- gifts
- inbox_comments
- invitations
- kudos
- log_items
- meta_taggings
- mutes
- preferences
- profiles
- prompts
- pseuds
- readings
- related_works
- set_taggings
- serial_works
- series
- skins
- stat_counters
- subscriptions
- tag_nominations
- tag_set_associations
- tag_sets
- taggings
- tags
- users
- works
Naming/VariableNumber:
AllowedIdentifiers:
- age_over_13
- no_age_over_13
Rails/DefaultScope:
Enabled: true
Rails/DynamicFindBy:
AllowedMethods:
# Exceptions for Tag.find_by_name and Tag.find_by_name!
- find_by_name
- find_by_name!
# Exception for Tagging.find_by_tag
- find_by_tag
# Exceptions for Work.find_by_*
- find_by_url
- find_by_url_cache_key
- find_by_url_generation
- find_by_url_generation_key
- find_by_url_uncached
# Exceptions for InboxComment.find_by_filters
- find_by_filters
Rails/EnvironmentVariableAccess:
Enabled: true
# Allow all uses of html_safe, they're everywhere...
Rails/OutputSafety:
Enabled: false
Rails/Output:
Exclude:
# Allow patches to print warnings to console:
- 'config/initializers/monkeypatches/*.rb'
# Allow migrations to print pt-osc comments to console:
- 'db/migrate/*.rb'
Rails/RakeEnvironment:
Enabled: false
Rails/ReversibleMigrationMethodDefinition:
Enabled: true
# Allow update_attribute, update_all, touch, etc.
Rails/SkipsModelValidations:
Enabled: false
Rails/UnknownEnv:
Environments:
- development
- test
- staging
- production
RSpec:
Include:
- "(?:^|/)factories/"
- "(?:^|/)features/"
- "(?:^|/)spec/"
# Allow "allow_any_instance_of"
RSpec/AnyInstance:
Enabled: false
# By default allow only prefixes "when", "with", "without".
# We have too many, so let's allow everything.
RSpec/ContextWording:
Enabled: false
RSpec/DescribeClass:
Exclude:
# Exception for specs about I18n configurations
- 'spec/lib/i18n/**/*.rb'
# Exception for rake specs, where the top level describe uses a task name
- 'spec/lib/tasks/*.rake_spec.rb'
# Exception for integration specs, which may not test a specific class
- 'spec/requests/**/*.rb'
RSpec/DescribedClass:
Enabled: false
RSpec/ExampleLength:
Enabled: false
# Prefer: expect { run }.to change { Foo.bar }
# over: expect { run }.to change(Foo, :bar)
RSpec/ExpectChange:
EnforcedStyle: block
RSpec/FilePath:
Exclude:
# Exception for WorksController, whose many specs need multiple files
- 'spec/controllers/works/*.rb'
# Exception for concern specs, which may test multiple classes
- 'spec/models/concerns/**/*.rb'
# Avoid instance variables, except for those not assigned within the spec,
# e.g. @request.
RSpec/InstanceVariable:
AssignmentOnly: true
# Allow unreferenced let! calls for test setup
RSpec/LetSetup:
Enabled: false
# Allow both "have_received" and "receive" for expectations
RSpec/MessageSpies:
Enabled: false
# Allow multiple top level describes (rake specs)
RSpec/MultipleDescribes:
Enabled: false
# Allow unlimited expectations per test
RSpec/MultipleExpectations:
Enabled: false
# Allow unnamed subjects
RSpec/NamedSubject:
Enabled: false
# Allow unlimited nested groups
RSpec/NestedGroups:
Enabled: false
RSpec/PredicateMatcher:
Enabled: false
Style/AndOr:
EnforcedStyle: conditionals
Style/ClassAndModuleChildren:
Enabled: false
Style/Documentation:
Enabled: false
Style/EmptyMethod:
EnforcedStyle: expanded
# Prefer template tokens (like %{foo}) over annotated tokens (like %s)
Style/FormatStringToken:
EnforcedStyle: template
Style/FrozenStringLiteralComment:
Enabled: false
Style/GlobalVars:
AllowedVariables:
- $elasticsearch
- $rollout
Style/PercentLiteralDelimiters:
Exclude:
# Exception for Cucumber step definitions, where we heavily use %{} for strings
- 'features/**/*.rb'
# Stop checking if uses of "self" are redundant
Style/RedundantSelf:
Enabled: false
Style/SelectByRegexp:
Enabled: true
Style/SingleLineMethods:
AllowIfMethodIsEmpty: false
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/SymbolArray:
Enabled: false
Style/TernaryParentheses:
EnforcedStyle: require_parentheses_when_complex

1
.ruby-version Normal file
View file

@ -0,0 +1 @@
ruby-3.2.7

10
.simplecov Normal file
View file

@ -0,0 +1,10 @@
if ENV["CI"] == "true"
require "simplecov-cobertura"
SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter
end
SimpleCov.start "rails" do
add_filter "/factories/"
merge_timeout 7200
command_name ENV["TEST_GROUP"].gsub(/[^\w]/, "_") if ENV["TEST_GROUP"]
end

27
ACKNOWLEDGMENTS.md Normal file
View file

@ -0,0 +1,27 @@
Acknowledgments
=========
<p><a href="https://www.browserstack.com/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Browserstack.svg" width="200"/> BrowserStack</a> for our cross-browser testing tool.</p>
<!--cc0 image https://pixabay.com/en/capybara-mammal-capibara-animal-147184/ -->
<p><a href="http://teamcapybara.github.io/capybara/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/capabara.png" width="200"/> Capybara</a> for integration testing.</p>
<p><a href="https://codeship.com/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/logo_codeship_colour_whitebg.png" width="200"/> Codeship</a> for continuous integration.</p>
<p><a href="https://cucumber.io/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/logo-cucumber.png" width="200"/> Cucumber</a> for integration testing.</p>
<p><a href="https://www.debian.org/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Debian.png" width="200"/> Debian</a> for our Linux distribution.</p>
<p><a href="https://www.elastic.co/products/elasticsearch"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Elasticsearch-Logo-Color-H.png" width="200"/> Elasticsearch</a> for our tag searching.</p>
<p><a href="http://www.exim.org/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/exim.png" width="200"/> Exim</a> for mail sending.</p>
<p><a href="http://galeracluster.com/products/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/galera.png" width="200"/> Galera Cluster</a> for clustered MySQL.</p>
<p><a href="github.com/otwcode/otwarchive"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/GitHub_Logo.png" width="200"/> GitHub</a> for collaborative programming.</p>
<p><a href="http://www.haproxy.org/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/logo-med.png" width="200"/> HAProxy</a> for load balancing.</p>
<p><a href="https://houndci.com/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Hound-Dribbble.png" width="200"/> Hound</a> and <a href="https://github.com/reviewdog/reviewdog"><img alt="reviewdog logo" valign="middle" src="https://raw.githubusercontent.com/haya14busa/i/d598ed7dc49fefb0018e422e4c43e5ab8f207a6b/reviewdog/reviewdog.logo.png" width="200"/> reviewdog</a> for style guidance.</p>
<p><a href="https://otwarchive.atlassian.net/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/jira_rgb_blue.svg" width="200"/> Jira</a> for our issue tracking.</p>
<p><a href="https://nginx.org/en/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Nginx_logo.svg" width="200"/> NGINX</a> for our front end.</p>
<p><a href="https://memcached.org/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Memcached.png" width="200"/> Memcached</a> for caching.</p>
<p><a href="https://mariadb.com/products/mariadb-maxscale"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/maxscale.png" width="200"/> MaxScale</a> for MySQL load balancing.</p>
<p><a href="https://www.percona.com/software/mysql-database/percona-xtradb-cluster"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/logo_percona_xtradbcluster_new.png" width="200"/> Percona XtraDB Cluster</a> for our relational database.</p>
<p><a href="http://rubyonrails.org/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/rails.png" width="200"/> Rails</a> for our framework.</p>
<p><a href="https://redis.io/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Redis_Logo.svg" width="200"/> Redis</a> for NoSQL.</p>
<p><a href="http://rspec.info/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/rspec.png" width="200"/> RSpec</a> for unit tests.</p>
<p><a href="https://www.ruby-lang.org/en/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/ruby.png" width="200"/> Ruby</a> as our language.</p>
<p><a href="https://www.jetbrains.com/ruby/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/logo_RubyMine.svg" width="200"/> RubyMine</a> for our integrated development environment.</p>
<p><a href="https://sentry.io/"><svg class="css-lfbo6j e1igk8x04" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222 66" width="200" height="60"><path d="M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z" transform="translate(11, 11)" fill="#362d59"></path></svg> Sentry</a> for APM/application monitoring.</p>
<p><a href="https://slack.com/"><img alt="" valign="middle" src="http://media.archiveofourown.org/ao3/logos/Slack_RGB.svg" width="200"/> Slack</a> for communications.</p>

63
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,63 @@
# Contributing to OTW-Archive
## Reporting bugs
We maintain a [Jira issue tracker](https://otwarchive.atlassian.net/projects/AO3/issues) for developers,
and a [list of Known Issues](https://archiveofourown.org/known_issues) for
[Archive of Our Own](https://archiveofourown.org) users, neither of which are
publicly editable.
If you need help using the site, or want to report an issue you have found,
please [contact the AO3 Support team](https://archiveofourown.org/support). Our Support team is staffed by volunteers, so please wait for a response before submitting another ticket. Duplicate submissions will not make things happen faster.
## Reporting security issues
Please refer to [SECURITY.md](https://github.com/otwcode/otwarchive/blob/master/SECURITY.md).
## Updating documentation
Our [development wiki](https://github.com/otwcode/otwarchive/wiki) is publicly
editable. Unless a page says at the top that it should only be edited by
official OTW volunteers, please feel free to make changes!
## Suggesting new features
Please [contact the AO3 Support team](https://archiveofourown.org/support). Our Support team is staffed by volunteers, so please wait for a response before submitting another ticket. Duplicate submissions will not make things happen faster.
## Contributing code
**We only accept pull requests for issues we have already added to [Jira](https://otwarchive.atlassian.net)**,
with the exception of spelling corrections and documentation improvements
(e.g. any Markdown files). We also do not accept code generated by AI tools; for more information,
please refer to [our commit policy](https://github.com/otwcode/otwarchive/wiki/Commit-Policy#scary-legal-stuff).
Please check out our development wiki for more information on:
- [how to set up a development environment](https://github.com/otwcode/otwarchive/wiki)
- [code conventions](https://github.com/otwcode/otwarchive/wiki/Commit-policy)
### Workflow
1. If you're a new contributor, find a task on the [issues reserved for first timers](https://otwarchive.atlassian.net/issues/?filter=13119). Otherwise, or if you're up for a challenge, pick a task from the general [open and unassigned issues](https://otwarchive.atlassian.net/issues/?filter=10800). (If you're a new contributor, don't worry about claiming the issue for now. If you make a Jira account, you'll get permissions for claiming issues in step 5.)
2. Write code to address the issue.
3. Optional: Create a Jira account if you'd like the ability to comment on, assign, and transition issues. Please make sure the Full Name on your Jira account either closely matches the name you'd like us to credit in the release notes or includes it in parentheses, e.g. "Nickname (CREDIT NAME)."
4. Submit the code with a pull request following the checklist on [our template](https://github.com/otwcode/otwarchive/blob/master/.github/PULL_REQUEST_TEMPLATE.md).
5. Once you've submitted a pull request, we'll review your code and give you permissions on Jira. Please be patient with us! Due to our workload, it may take some time before we can review and eventually merge your pull request.
6. Once your pull request is merged, we will deploy it to our internal testing site and our QA team will check that everything is working as intended.
7. If something is not working as intended, we may set the issue to ["Broken on Test"](https://github.com/otwcode/otwarchive/wiki/Issue-Tracking-with-Jira) and ask you to make further changes in new pull requests.
8. If all is well, your contribution will be deployed to the [Archive of Our Own](https://archiveofourown.org) and you will be credited in the [release notes](https://archiveofourown.org/admin_posts?tag=1)!
## Volunteering for the OTW
If you would like to donate more of your time and expertise in a multi-national,
inclusive, fandom-oriented team, you might enjoy [becoming an official OTW volunteer](http://transformativeworks.org/how-you-can-help/volunteer).
## Questions?
[Drop us an email](mailto:otw-coders@transformativeworks.org) if you have any questions.

4
Capfile Normal file
View file

@ -0,0 +1,4 @@
load 'deploy' if respond_to?(:namespace) # cap2 differentiator
Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) }
load 'config/deploy' # remove this line to skip loading any of the default tasks

186
Gemfile Normal file
View file

@ -0,0 +1,186 @@
source 'https://rubygems.org'
ruby "3.2.7"
gem 'test-unit', '~> 3.2'
gem 'bundler'
gem "rails", "~> 7.2"
gem "rails-i18n"
gem "rack", "~> 2.2"
gem "sprockets", "< 4"
gem 'rails-observers', git: 'https://github.com/rails/rails-observers'
gem 'actionpack-page_caching'
gem 'rails-controller-testing'
# Database
# gem 'sqlite3-ruby', require: 'sqlite3'
gem "mysql2"
gem 'rack-attack'
# Version of redis-rb gem
# We are currently running Redis 3.2.1 (7/2018)
gem "redis", "~> 3.3.5"
gem 'redis-namespace'
# Here are all our application-specific gems
# Used to convert strings to ascii
gem 'unicode'
gem 'unidecoder'
gem 'unicode_utils', '>=1.4.0'
# Lograge is opinionated, very opinionated.
gem "lograge" # https://github.com/roidrage/lograge
gem 'will_paginate', '>=3.0.2'
gem "pagy", "~> 9.3"
gem 'acts_as_list', '~> 0.9.7'
gem 'akismetor'
gem 'httparty'
gem 'htmlentities'
gem 'whenever', '~>0.6.2', require: false
gem 'nokogiri', '>= 1.8.5'
gem 'mechanize'
gem 'sanitize', '>= 4.6.5'
gem "rest-client", "~> 2.1.0", require: "rest_client"
gem 'resque', '>=1.14.0'
gem 'resque-scheduler'
gem 'after_commit_everywhere'
#gem 'daemon-spawn', require: 'daemon_spawn'
gem "elasticsearch", "8.18.0"
gem "aws-sdk-s3"
gem 'css_parser'
gem "terrapin"
# for looking up image dimensions quickly
gem 'fastimage'
# Gems for authentication
gem "devise"
gem "devise-async" # To mails through queues
gem "bcrypt"
gem "devise-pwned_password"
# Needed for modern ssh
gem "ed25519", ">= 1.2", "< 2.0"
gem "bcrypt_pbkdf", ">= 1.0", "< 2.0"
# A highly updated version of the authorization plugin
gem 'permit_yo'
gem "pundit"
# fix for annoying UTF-8 error messages as per this:
# http://openhood.com/rack/ruby/2010/07/15/rack-test-warning/
gem 'escape_utils', '1.2.1'
gem 'timeliness'
# for generating graphs
gem 'google_visualr', git: 'https://github.com/winston/google_visualr'
# Globalize for translations
gem "globalize", "~> 7.0"
# Add a clean notifier that shows we are on dev or test
gem 'rack-dev-mark', '>=0.7.8'
#Phrase-app
gem 'phraseapp-in-context-editor-ruby', '>=1.0.6'
# For URL mangling
gem 'addressable'
gem 'audited', '~> 5.3'
# For controlling application behavour dynamically
gem 'rollout'
# Use update memcached client with kinder, gentler I/O for Ruby
gem 'connection_pool'
gem 'dalli'
gem 'kgio', '2.10.0'
gem "marcel", "1.0.2"
# Library for helping run pt-online-schema-change commands:
gem "departure", "~> 6.8"
gem "rack-timeout"
gem "puma_worker_killer"
group :test do
gem "rspec-rails", "~> 6.0"
gem 'pickle'
gem 'shoulda'
gem "capybara"
gem "cucumber"
gem 'database_cleaner'
gem "selenium-webdriver"
gem 'capybara-screenshot'
gem 'cucumber-rails', require: false
gem 'launchy' # So you can do Then show me the page
# Record and replay data from external URLs
gem "vcr", "~> 6.2"
gem "webmock"
gem 'timecop'
gem 'cucumber-timecop', require: false
# Code coverage
gem "simplecov"
gem "simplecov-cobertura", require: false
gem 'email_spec', '1.6.0'
gem "n_plus_one_control"
end
group :test, :development do
gem 'awesome_print'
gem 'brakeman'
gem 'pry-byebug'
gem 'whiny_validation'
gem "factory_bot_rails"
gem 'minitest'
gem "i18n-tasks", require: false
end
group :development do
gem 'bundler-audit'
gem 'active_record_query_trace', '~> 1.6', '>= 1.6.1'
end
group :linters do
gem "erb_lint", "0.4.0"
gem "rubocop", "1.22.3"
gem "rubocop-rails", "2.12.4"
gem "rubocop-rspec", "2.6.0"
end
group :test, :development, :staging do
gem 'bullet', '>= 5.7.3'
gem "factory_bot", require: false
gem "faker", require: false
end
# Deploy with Capistrano
gem 'capistrano-gitflow_version', '>=0.0.3', require: false
gem 'rvm-capistrano'
# Use unicorn as the web server
gem 'unicorn', '~> 5.5', require: false
# Install puma so we can migrate to it
gem "puma", "~> 6.5.0"
# Use god as the monitor
gem 'god', '~> 0.13.7'
group :staging, :production do
gem "stackprof"
gem "sentry-ruby"
gem "sentry-rails"
gem "sentry-resque"
end
gem "image_processing", "~> 1.12"

781
Gemfile.lock Normal file
View file

@ -0,0 +1,781 @@
GIT
remote: https://github.com/rails/rails-observers
revision: 389b577322d8b17336730b2dc4e179060a23c8e7
specs:
rails-observers (0.2.0)
activemodel (>= 4.2)
GIT
remote: https://github.com/winston/google_visualr
revision: 17b97114a345baadd011e7b442b9a6c91a2b7ab5
specs:
google_visualr (2.5.1)
GEM
remote: https://rubygems.org/
specs:
aaronh-chronic (0.3.9)
actioncable (7.2.2.2)
actionpack (= 7.2.2.2)
activesupport (= 7.2.2.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.2.2)
actionpack (= 7.2.2.2)
activejob (= 7.2.2.2)
activerecord (= 7.2.2.2)
activestorage (= 7.2.2.2)
activesupport (= 7.2.2.2)
mail (>= 2.8.0)
actionmailer (7.2.2.2)
actionpack (= 7.2.2.2)
actionview (= 7.2.2.2)
activejob (= 7.2.2.2)
activesupport (= 7.2.2.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.2.2)
actionview (= 7.2.2.2)
activesupport (= 7.2.2.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actionpack-page_caching (1.2.4)
actionpack (>= 4.0.0)
actiontext (7.2.2.2)
actionpack (= 7.2.2.2)
activerecord (= 7.2.2.2)
activestorage (= 7.2.2.2)
activesupport (= 7.2.2.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.2.2)
activesupport (= 7.2.2.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8.2)
activerecord (>= 6.0.0)
activejob (7.2.2.2)
activesupport (= 7.2.2.2)
globalid (>= 0.3.6)
activemodel (7.2.2.2)
activesupport (= 7.2.2.2)
activerecord (7.2.2.2)
activemodel (= 7.2.2.2)
activesupport (= 7.2.2.2)
timeout (>= 0.4.0)
activestorage (7.2.2.2)
actionpack (= 7.2.2.2)
activejob (= 7.2.2.2)
activerecord (= 7.2.2.2)
activesupport (= 7.2.2.2)
marcel (~> 1.0)
activesupport (7.2.2.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
acts_as_list (0.9.19)
activerecord (>= 3.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
after_commit_everywhere (1.6.0)
activerecord (>= 4.2)
activesupport
akismetor (1.0.0)
ast (2.4.3)
audited (5.8.0)
activerecord (>= 5.2, < 8.2)
activesupport (>= 5.2, < 8.2)
awesome_print (1.9.2)
aws-eventstream (1.3.0)
aws-partitions (1.895.0)
aws-sdk-core (3.191.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.0)
benchmark (0.4.1)
better_html (2.0.2)
actionview (>= 6.0)
activesupport (>= 6.0)
ast (~> 2.0)
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.2.2)
brakeman (7.0.2)
racc
builder (3.3.0)
bullet (8.0.8)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (12.0.0)
capistrano (2.15.11)
highline
net-scp (>= 1.0.0)
net-sftp (>= 2.0.0)
net-ssh (>= 2.0.14)
net-ssh-gateway (>= 1.1.0)
capistrano-ext (1.2.1)
capistrano (>= 1.0.0)
capistrano-gitflow_version (0.0.3.1)
capistrano-ext (>= 1.2.1)
stringex
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
capybara-screenshot (1.0.26)
capybara (>= 1.0, < 4)
launchy
chronic (0.10.2)
climate_control (1.2.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.16.0)
addressable
cucumber (9.2.1)
builder (~> 3.2)
cucumber-ci-environment (> 9, < 11)
cucumber-core (> 13, < 14)
cucumber-cucumber-expressions (~> 17.0)
cucumber-gherkin (> 24, < 28)
cucumber-html-formatter (> 20.3, < 22)
cucumber-messages (> 19, < 25)
diff-lcs (~> 1.5)
mini_mime (~> 1.1)
multi_test (~> 1.1)
sys-uname (~> 1.2)
cucumber-ci-environment (10.0.1)
cucumber-core (13.0.3)
cucumber-gherkin (>= 27, < 28)
cucumber-messages (>= 20, < 23)
cucumber-tag-expressions (> 5, < 7)
cucumber-cucumber-expressions (17.1.0)
bigdecimal
cucumber-gherkin (27.0.0)
cucumber-messages (>= 19.1.4, < 23)
cucumber-html-formatter (21.13.0)
cucumber-messages (> 19, < 28)
cucumber-messages (22.0.0)
cucumber-rails (3.1.1)
capybara (>= 3.11, < 4)
cucumber (>= 5, < 10)
railties (>= 5.2, < 9)
cucumber-tag-expressions (6.1.2)
cucumber-timecop (0.0.6)
chronic
cucumber
timecop
dalli (3.2.8)
database_cleaner (2.1.0)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.2.1)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.4.1)
departure (6.8.0)
activerecord (>= 6.0.0, < 7.3.0, != 7.0.0)
mysql2 (>= 0.4.0, < 0.6.0)
railties (>= 6.0.0, < 7.3.0, != 7.0.0)
devise (4.9.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-async (1.0.0)
activejob (>= 5.0)
devise (>= 4.0)
devise-pwned_password (0.1.12)
devise (~> 4)
pwned (~> 2.4)
diff-lcs (1.6.2)
docile (1.4.1)
domain_name (0.6.20240107)
drb (2.2.3)
ed25519 (1.3.0)
elastic-transport (8.3.5)
faraday (< 3)
multi_json
elasticsearch (8.18.0)
elastic-transport (~> 8.3)
elasticsearch-api (= 8.18.0)
elasticsearch-api (8.18.0)
multi_json
email_spec (1.6.0)
launchy (~> 2.1)
mail (~> 2.2)
erb (5.0.2)
erb_lint (0.4.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
rainbow
rubocop
smart_properties
erubi (1.13.1)
escape_utils (1.2.1)
et-orbi (1.2.11)
tzinfo
factory_bot (6.5.4)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.0)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
fastimage (2.3.0)
ffi (1.17.2)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
get_process_mem (1.0.0)
bigdecimal (>= 2.0)
ffi (~> 1.0)
globalid (1.2.1)
activesupport (>= 6.1)
globalize (7.0.0)
activemodel (>= 7.0, < 8.1)
activerecord (>= 7.0, < 8.1)
activesupport (>= 7.0, < 8.1)
request_store (~> 1.0)
god (0.13.7)
hashdiff (1.2.0)
highline (3.1.2)
reline
htmlentities (4.3.4)
http-accept (1.7.0)
http-cookie (1.0.5)
domain_name (~> 0.5)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.15)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
erubi
highline (>= 2.0.0)
i18n
parser (>= 3.2.2.1)
rails-i18n
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.8, >= 1.8.1)
terminal-table (>= 1.5.1)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.12.2)
kgio (2.10.0)
launchy (2.5.2)
addressable (~> 2.8)
logger (1.7.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.2)
matrix (0.4.3)
mechanize (2.12.2)
addressable (~> 2.8)
base64
domain_name (~> 0.5, >= 0.5.20190701)
http-cookie (~> 1.0, >= 1.0.3)
mime-types (~> 3.3)
net-http-digest_auth (~> 1.4, >= 1.4.1)
net-http-persistent (>= 2.5.2, < 5.0.dev)
nkf
nokogiri (~> 1.11, >= 1.11.2)
rubyntlm (~> 0.6, >= 0.6.3)
webrick (~> 1.7)
webrobots (~> 0.1.2)
method_source (1.1.0)
mime-types (3.5.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2024.0206)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
mono_logger (1.1.2)
multi_json (1.15.0)
multi_test (1.1.0)
multi_xml (0.6.0)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
mysql2 (0.5.6)
n_plus_one_control (0.7.2)
net-http (0.6.0)
uri
net-http-digest_auth (1.4.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.5.9)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
net-ssh (7.2.1)
net-ssh-gateway (2.0.0)
net-ssh (>= 4.0.0)
netrc (0.11.0)
nio4r (2.7.4)
nkf (0.2.0)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
orm_adapter (0.5.0)
pagy (9.3.3)
parallel (1.25.1)
parser (3.3.8.0)
ast (~> 2.4.1)
racc
permit_yo (2.1.3)
phraseapp-in-context-editor-ruby (1.4.0)
i18n (>= 0.6)
json (>= 1.8, < 3)
phraseapp-ruby (~> 1.3)
request_store (~> 1.3)
phraseapp-ruby (1.6.0)
pickle (0.9.0)
cucumber (>= 3.0, < 10.0)
rake
power_assert (2.0.3)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
pry (0.15.2)
coderay (~> 1.1)
method_source (~> 1.0)
pry-byebug (3.11.0)
byebug (~> 12.0)
pry (>= 0.13, < 0.16)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
puma (6.5.0)
nio4r (~> 2.0)
puma_worker_killer (1.0.0)
bigdecimal (>= 2.0)
get_process_mem (>= 0.2)
puma (>= 2.7)
pundit (2.3.1)
activesupport (>= 3.0.0)
pwned (2.4.1)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.17)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-dev-mark (0.8.0)
rack (>= 1.1, < 4.0)
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-session (1.0.2)
rack (< 3)
rack-test (2.2.0)
rack (>= 1.3)
rack-timeout (0.7.0)
rackup (1.0.1)
rack (< 3)
webrick
rails (7.2.2.2)
actioncable (= 7.2.2.2)
actionmailbox (= 7.2.2.2)
actionmailer (= 7.2.2.2)
actionpack (= 7.2.2.2)
actiontext (= 7.2.2.2)
actionview (= 7.2.2.2)
activejob (= 7.2.2.2)
activemodel (= 7.2.2.2)
activerecord (= 7.2.2.2)
activestorage (= 7.2.2.2)
activesupport (= 7.2.2.2)
bundler (>= 1.15.0)
railties (= 7.2.2.2)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.2.2.2)
actionpack (= 7.2.2.2)
activesupport (= 7.2.2.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
raindrops (0.20.1)
rake (13.3.0)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
redis (3.3.5)
redis-namespace (1.8.2)
redis (>= 3.0.4)
regexp_parser (2.10.0)
reline (0.6.2)
io-console (~> 0.5)
request_store (1.6.0)
rack (>= 1.4)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
resque (2.6.0)
mono_logger (~> 1.0)
multi_json (~> 1.0)
redis-namespace (~> 1.6)
sinatra (>= 0.9.2)
resque-scheduler (4.10.2)
mono_logger (~> 1.0)
redis (>= 3.3)
resque (>= 1.27)
rufus-scheduler (~> 3.2, != 3.3)
rest-client (2.1.0)
http-accept (>= 1.7.0, < 2.0)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
rexml (3.4.1)
rollout (2.4.3)
rspec-core (3.13.5)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.5)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.4)
rubocop (1.22.3)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.31.2)
parser (>= 3.3.0.4)
rubocop-rails (2.12.4)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.6.0)
rubocop (~> 1.19)
ruby-progressbar (1.13.0)
ruby-vips (2.2.1)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
rubyntlm (0.6.3)
rubyzip (2.4.1)
rufus-scheduler (3.9.1)
fugit (~> 1.1, >= 1.1.6)
rvm-capistrano (1.5.6)
capistrano (~> 2.15.4)
sanitize (6.1.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
securerandom (0.4.1)
selenium-webdriver (4.34.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.18.0)
railties (>= 5.0)
sentry-ruby (~> 5.18.0)
sentry-resque (5.18.0)
resque (>= 1.24)
sentry-ruby (~> 5.18.0)
sentry-ruby (5.18.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shoulda (4.0.0)
shoulda-context (~> 2.0)
shoulda-matchers (~> 4.0)
shoulda-context (2.0.0)
shoulda-matchers (4.5.1)
activesupport (>= 4.2.0)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-cobertura (3.0.0)
rexml
simplecov (~> 0.19)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
sinatra (3.2.0)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.2.0)
tilt (~> 2.0)
smart_properties (1.17.0)
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
stackprof (0.2.26)
stringex (2.8.6)
stringio (3.1.7)
sys-uname (1.3.1)
ffi (~> 1.1)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.0.1)
climate_control
test-unit (3.6.2)
power_assert
thor (1.4.0)
tilt (2.3.0)
timecop (0.9.10)
timeliness (0.4.5)
timeout (0.4.3)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
unicode-display_width (2.6.0)
unicode_utils (1.4.0)
unicorn (5.8.0)
kgio (~> 2.6)
raindrops (~> 0.7)
unidecoder (1.1.2)
uniform_notifier (1.17.0)
uri (1.0.3)
useragent (0.16.11)
vcr (6.3.1)
base64
warden (1.2.9)
rack (>= 2.0.9)
webmock (3.25.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
webrobots (0.1.2)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (0.6.8)
aaronh-chronic (>= 0.3.9)
activesupport (>= 2.3.4)
whiny_validation (1.1)
activemodel
activesupport
will_paginate (4.0.0)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
PLATFORMS
ruby
DEPENDENCIES
actionpack-page_caching
active_record_query_trace (~> 1.6, >= 1.6.1)
acts_as_list (~> 0.9.7)
addressable
after_commit_everywhere
akismetor
audited (~> 5.3)
awesome_print
aws-sdk-s3
bcrypt
bcrypt_pbkdf (>= 1.0, < 2.0)
brakeman
bullet (>= 5.7.3)
bundler
bundler-audit
capistrano-gitflow_version (>= 0.0.3)
capybara
capybara-screenshot
connection_pool
css_parser
cucumber
cucumber-rails
cucumber-timecop
dalli
database_cleaner
departure (~> 6.8)
devise
devise-async
devise-pwned_password
ed25519 (>= 1.2, < 2.0)
elasticsearch (= 8.18.0)
email_spec (= 1.6.0)
erb_lint (= 0.4.0)
escape_utils (= 1.2.1)
factory_bot
factory_bot_rails
faker
fastimage
globalize (~> 7.0)
god (~> 0.13.7)
google_visualr!
htmlentities
httparty
i18n-tasks
image_processing (~> 1.12)
kgio (= 2.10.0)
launchy
lograge
marcel (= 1.0.2)
mechanize
minitest
mysql2
n_plus_one_control
nokogiri (>= 1.8.5)
pagy (~> 9.3)
permit_yo
phraseapp-in-context-editor-ruby (>= 1.0.6)
pickle
pry-byebug
puma (~> 6.5.0)
puma_worker_killer
pundit
rack (~> 2.2)
rack-attack
rack-dev-mark (>= 0.7.8)
rack-timeout
rails (~> 7.2)
rails-controller-testing
rails-i18n
rails-observers!
redis (~> 3.3.5)
redis-namespace
resque (>= 1.14.0)
resque-scheduler
rest-client (~> 2.1.0)
rollout
rspec-rails (~> 6.0)
rubocop (= 1.22.3)
rubocop-rails (= 2.12.4)
rubocop-rspec (= 2.6.0)
rvm-capistrano
sanitize (>= 4.6.5)
selenium-webdriver
sentry-rails
sentry-resque
sentry-ruby
shoulda
simplecov
simplecov-cobertura
sprockets (< 4)
stackprof
terrapin
test-unit (~> 3.2)
timecop
timeliness
unicode
unicode_utils (>= 1.4.0)
unicorn (~> 5.5)
unidecoder
vcr (~> 6.2)
webmock
whenever (~> 0.6.2)
whiny_validation
will_paginate (>= 3.0.2)
RUBY VERSION
ruby 3.2.7p253
BUNDLED WITH
2.6.3

339
LICENSE Normal file
View file

@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/>
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
{description}
Copyright (C) {year} {fullname}
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
{signature of Ty Coon}, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

69
README.md Normal file
View file

@ -0,0 +1,69 @@
OTW-Archive
=========
this is the fork of the OTW's OTW-Archive software that powers symphony and sunset archives. the views and such are for symphony; the sunset version is coming soon.
features:
-status updates
-random user button
-estimated reading time for each work
more always in progress!
current features in progress:
-forums
Credits for features not made by me
======
Reading time estimation: [paintedflowers](https://bytes.4-walls.net/paintedflowers/Futurethingstoteswiitotwcode) on Bytes
---------
[![Build Status](https://img.shields.io/github/actions/workflow/status/otwcode/otwarchive/automated-tests.yml?branch=master)](https://github.com/otwcode/otwarchive/actions/workflows/automated-tests.yml?query=branch%3Amaster) [![Codeship Status](https://img.shields.io/codeship/1f7468f0-7e15-0131-c059-7a8d26daf885/master.svg?label=codeship)](https://www.codeship.io/projects/14476) [![Coverage Status](https://img.shields.io/codecov/c/github/otwcode/otwarchive/master.svg)](https://app.codecov.io/gh/otwcode/otwarchive)
The OTW-Archive software is an open-source web application intended for hosting archives of fanworks, including fanfic, fanart, and fan vids.
Its development is sponsored and managed by the [Organization for Transformative Works](https://www.transformativeworks.org/), a nonprofit organization by and for fans.
Release Status
---------
Development of the OTW-Archive software is an ongoing labor of love. You can see it in action on the [Archive of Our Own](https://archiveofourown.org/), aka AO3, a multifandom archive also run by the OTW.
You can find more information about the [history and future of the AO3 project on the OTW website](https://www.transformativeworks.org/archive_of_our_own/).
If you wish to use this software, SquidgeWorld has generously provided [setup notes](https://squidgeworld.org/works/34491).
How to Contribute
----------
We welcome pull requests for bugs described in our issue tracker. Please see our [Contributing Guidelines](https://github.com/otwcode/otwarchive/blob/master/CONTRIBUTING.md) for further information!
* [Bug Tracker](https://otwarchive.atlassian.net/projects/AO3/issues)
* [Developer Documentation](https://github.com/otwcode/otwarchive/wiki)
* [Commit Policy](https://github.com/otwcode/otwarchive/wiki/Commit-policy)
We do not have a public chat, but you are welcome to contact us at otw-coders@transformativeworks.org if you have any questions.
We grant your Jira account permissions for commenting on, assigning, and transitioning issues [after you create your first pull request](https://github.com/otwcode/otwarchive/blob/master/CONTRIBUTING.md#workflow).
API
----------
There is currently no API for the OTW-Archive software. While it is something we're considering for the future, we ask that contributors instead focus on issues already in our [Jira issue tracker](https://otwarchive.atlassian.net/).
License and Acknowledgments
----------
The Archive code is licensed under [GPL-2.0-or-later](https://www.gnu.org/licenses/gpl-2.0.html) by the [Organization for Transformative Works](https://www.transformativeworks.org/).
We benefit from software and services that are free to use for Open Source projects, including:
* [RubyMine IDE](https://www.jetbrains.com/ruby/) by JetBrains
* [Codeship](https://codeship.com/)
* [Hound](https://houndci.com/) by [thoughtbot](https://thoughtbot.com/)
* [BrowserStack](https://www.browserstack.com)
* [Sentry](https://sentry.io)
* [Full list of acknowledgments](ACKNOWLEDGMENTS.md)
Thank you kindly!

9
Rakefile Normal file
View file

@ -0,0 +1,9 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require File.expand_path('../config/application', __FILE__)
require 'rake'
require 'resque/tasks'
include Rake::DSL
Otwarchive::Application.load_tasks

14
SECURITY.md Normal file
View file

@ -0,0 +1,14 @@
# Reporting a security issue
Please email us directly at [otw-coders@transformativeworks.org](mailto:otw-coders@transformativeworks.org)
with details and reproduction steps. We will get back to you as soon as possible,
and we may ask for additional information or guidance.
Please avoid testing for security issues on the Archive of Our Own itself,
as you risk disrupting other users and violating the [Terms of Service](https://archiveofourown.org/content#II.K.1).
Please give us a reasonable amount of time to address the issue before any
disclosure to the public or a third-party.
We thank you for your effort and responsible disclosure! We will make sure you
receive due credit on [our public release notes](https://archiveofourown.org/admin_posts?tag=1).

View file

@ -0,0 +1,38 @@
class AbuseReportsController < ApplicationController
skip_before_action :store_location
before_action :load_abuse_languages
def new
@abuse_report = AbuseReport.new
reporter = current_admin || current_user
if reporter.present?
@abuse_report.email = reporter.email
@abuse_report.username = reporter.login
end
@abuse_report.url = params[:url] || request.env["HTTP_REFERER"]
end
def create
@abuse_report = AbuseReport.new(abuse_report_params)
@abuse_report.ip_address = request.remote_ip
if @abuse_report.save
@abuse_report.email_and_send
flash[:notice] = ts("Your report was submitted to the Policy & Abuse team. A confirmation message has been sent to the email address you provided.")
redirect_to root_path
else
render action: "new"
end
end
private
def load_abuse_languages
@abuse_languages = Language.where(abuse_support_available: true).default_order
end
def abuse_report_params
params.require(:abuse_report).permit(
:username, :email, :language, :summary, :url, :comment
)
end
end

View file

@ -0,0 +1,11 @@
class Admin::ActivitiesController < Admin::BaseController
def index
@activities = AdminActivity.order("created_at DESC").page(params[:page])
authorize @activities
end
def show
@activity = AdminActivity.find(params[:id])
authorize @activity
end
end

View file

@ -0,0 +1,61 @@
class Admin::AdminInvitationsController < Admin::BaseController
def index
end
def create
@invitation = current_admin.invitations.new(invitation_params)
if @invitation.invitee_email.blank?
flash[:error] = t('no_email', default: "Please enter an email address.")
render action: 'index'
elsif @invitation.save
flash[:notice] = t('sent', default: "An invitation was sent to %{email_address}", email_address: @invitation.invitee_email)
redirect_to admin_invitations_path
else
render action: 'index'
end
end
def invite_from_queue
count = invitation_params[:invite_from_queue].to_i
InviteFromQueueJob.perform_later(count: count, creator: current_admin)
flash[:notice] = t(".success", count: count)
redirect_to admin_invitations_path
end
def grant_invites_to_users
if invitation_params[:user_group] == "All"
Invitation.grant_all(invitation_params[:number_of_invites].to_i)
else
Invitation.grant_empty(invitation_params[:number_of_invites].to_i)
end
flash[:notice] = t('invites_created', default: 'Invitations successfully created.')
redirect_to admin_invitations_path
end
def find
unless invitation_params[:user_name].blank?
@user = User.find_by(login: invitation_params[:user_name])
@hide_dashboard = true
@invitations = @user.invitations if @user
end
if !invitation_params[:token].blank?
@invitations = Invitation.where(token: invitation_params[:token])
elsif invitation_params[:invitee_email].present?
@invitations = Invitation.where("invitee_email LIKE ?", "%#{invitation_params[:invitee_email]}%")
end
return if @user || @invitations.present?
flash.now[:error] = t(".user_not_found")
end
private
def invitation_params
params.require(:invitation).permit(:invitee_email, :invite_from_queue,
:user_group, :number_of_invites, :user_name, :token)
end
end

View file

@ -0,0 +1,237 @@
class Admin::AdminUsersController < Admin::BaseController
include ExportsHelper
before_action :set_roles, only: [:index, :bulk_search]
before_action :load_user, only: [:show, :update, :confirm_delete_user_creations, :destroy_user_creations, :troubleshoot, :activate, :creations]
before_action :user_is_banned, only: [:confirm_delete_user_creations, :destroy_user_creations]
before_action :load_user_creations, only: [:confirm_delete_user_creations, :creations]
def set_roles
@roles = Role.assignable.distinct
end
def load_user
@user = User.find_by!(login: params[:id])
end
def user_is_banned
return if @user&.banned?
flash[:error] = ts("That user is not banned!")
redirect_to admin_users_path
end
def load_user_creations
@works = @user.works.paginate(page: params[:works_page])
@comments = @user.comments.paginate(page: params[:comments_page])
end
def index
authorize User
# Values for the role dropdown:
@role_values = @roles.map { |role| [role.name.humanize.titlecase, role.id] }
return if search_params.empty?
@query = UserQuery.new(search_params)
@users = @query.search_results.scope(:with_includes_for_admin_index)
end
def bulk_search
authorize User
@emails = params[:emails].split if params[:emails]
if @emails.present?
found_users, not_found_emails, duplicates = User.search_multiple_by_email(@emails)
@users = found_users.paginate(page: params[:page] || 1)
if params[:download_button]
header = [%w(Email Username)]
found = found_users.map { |u| [u.email, u.login] }
not_found = not_found_emails.map { |email| [email, ""] }
send_csv_data(header + found + not_found, "bulk_user_search_#{Time.now.strftime("%Y-%m-%d-%H%M")}.csv")
flash.now[:notice] = ts("Downloaded CSV")
end
@results = {
total: @emails.size,
searched: found_users.size + not_found_emails.size,
found_users: found_users,
not_found_emails: not_found_emails,
duplicates: duplicates
}
else
@results = {}
end
end
# GET admin/users/1
def show
authorize @user
@page_subtitle = t(".page_title", login: @user.login)
log_items
end
# POST admin/users/update
def update
authorize @user
attributes = permitted_attributes(@user)
if attributes[:email].present?
@user.skip_reconfirmation!
@user.email = attributes[:email]
end
if attributes[:roles].present?
# Roles that the current admin can add or remove
allowed_roles = UserPolicy::ALLOWED_USER_ROLES_BY_ADMIN_ROLES
.values_at(*current_admin.roles)
.compact
.flatten
# Other roles the current user has
out_of_scope_roles = @user.roles.to_a.reject { |role| allowed_roles.include?(role.name) }
request_roles = Role.where(
id: attributes[:roles],
name: [allowed_roles]
)
@user.roles = out_of_scope_roles + request_roles
end
if @user.save
flash[:notice] = ts("User was successfully updated.")
else
flash[:error] = ts("The user %{name} could not be updated: %{errors}", name: params[:id], errors: @user.errors.full_messages.join(" "))
end
redirect_to request.referer || root_path
end
def update_next_of_kin
@user = authorize User.find_by!(login: params[:user_login])
kin = User.find_by(login: params[:next_of_kin_name])
kin_email = params[:next_of_kin_email]
fnok = @user.fannish_next_of_kin
previous_kin = fnok&.kin
fnok ||= @user.build_fannish_next_of_kin
fnok.assign_attributes(kin: kin, kin_email: kin_email)
unless fnok.changed?
flash[:notice] = ts("No change to fannish next of kin.")
redirect_to admin_user_path(@user) and return
end
# Remove FNOK that already exists.
if fnok.persisted? && kin.blank? && kin_email.blank?
fnok.destroy
@user.log_removal_of_next_of_kin(previous_kin, admin: current_admin)
flash[:notice] = ts("Fannish next of kin was removed.")
redirect_to admin_user_path(@user) and return
end
if fnok.save
@user.log_removal_of_next_of_kin(previous_kin, admin: current_admin)
@user.log_assignment_of_next_of_kin(kin, admin: current_admin)
flash[:notice] = ts("Fannish next of kin was updated.")
redirect_to admin_user_path(@user)
else
@hide_dashboard = true
log_items
render :show
end
end
def update_status
@user = User.find_by!(login: params[:user_login])
# Authorize on the manager, as we need to check which specific actions the admin can do.
@user_manager = authorize UserManager.new(current_admin, @user, params)
if @user_manager.save
flash[:notice] = @user_manager.success_message
if @user_manager.admin_action == "spamban"
redirect_to confirm_delete_user_creations_admin_user_path(@user)
else
redirect_to admin_user_path(@user)
end
else
flash[:error] = @user_manager.error_message
redirect_to admin_user_path(@user)
end
end
def confirm_delete_user_creations
authorize @user
@bookmarks = @user.bookmarks
@collections = @user.sole_owned_collections
@series = @user.series
@page_subtitle = t(".page_title", login: @user.login)
end
def destroy_user_creations
authorize @user
creations = @user.works + @user.bookmarks + @user.sole_owned_collections
creations.each do |creation|
AdminActivity.log_action(current_admin, creation, action: "destroy spam", summary: creation.inspect)
creation.mark_as_spam! if creation.respond_to?(:mark_as_spam!)
creation.destroy
end
# comments are special and needs to be handled separately
@user.comments.not_deleted.each do |comment|
AdminActivity.log_action(current_admin, comment, action: "destroy spam", summary: comment.inspect)
comment.submit_spam
comment.destroy_or_mark_deleted # comments with replies cannot be destroyed, mark deleted instead
end
flash[:notice] = t(".success", login: @user.login)
redirect_to(admin_users_path)
end
def troubleshoot
authorize @user
@user.fix_user_subscriptions
@user.set_user_work_dates
@user.reindex_user_creations
@user.update_works_index_timestamp!
@user.create_log_item(options = { action: ArchiveConfig.ACTION_TROUBLESHOOT, admin_id: current_admin.id })
flash[:notice] = ts("User account troubleshooting complete.")
redirect_to(request.env["HTTP_REFERER"] || root_path) && return
end
def activate
authorize @user
@user.activate
if @user.active?
@user.create_log_item( options = { action: ArchiveConfig.ACTION_ACTIVATE, note: "Manually Activated", admin_id: current_admin.id })
flash[:notice] = ts("User Account Activated")
redirect_to action: :show
else
flash[:error] = ts("Attempt to activate account failed.")
redirect_to action: :show
end
end
def creations
authorize @user
@page_subtitle = t(".page_title", login: @user.login)
end
private
def search_params
allowed_params = if policy(User).can_view_past?
%i[name email role_id user_id inactive page commit search_past]
else
%i[name email role_id user_id inactive page commit]
end
params.permit(*allowed_params)
end
def log_items
@log_items ||= @user.log_items.sort_by(&:created_at).reverse
end
end

View file

@ -0,0 +1,71 @@
class Admin::ApiController < Admin::BaseController
before_action :check_for_cancel, only: [:create, :update]
def index
@page_subtitle = t(".page_title")
@api_keys = if params[:query]
sql_query = "%" + params[:query] + "%"
ApiKey.where("name LIKE ?", sql_query).order("name").paginate(page: params[:page])
else
ApiKey.order("name").paginate(page: params[:page])
end
authorize @api_keys
end
def show
redirect_to action: "index"
end
def new
@page_subtitle = t(".page_title")
@api_key = authorize ApiKey.new
end
def create
authorize ApiKey
# Use provided api key params if available otherwise fallback to empty
# ApiKey object
@api_key = params[:api_key].nil? ? ApiKey.new : ApiKey.new(api_key_params)
if @api_key.save
flash[:notice] = ts("New token successfully created")
redirect_to action: "index"
else
render "new"
end
end
def edit
@page_subtitle = t(".page_title")
@api_key = authorize ApiKey.find(params[:id])
end
def update
@api_key = authorize ApiKey.find(params[:id])
if @api_key.update(api_key_params)
flash[:notice] = ts("Access token was successfully updated")
redirect_to action: "index"
else
render "edit"
end
end
def destroy
@api_key = authorize ApiKey.find(params[:id])
@api_key.destroy
redirect_to(admin_api_path)
end
private
def api_key_params
params.require(:api_key).permit(
:name, :access_token, :banned
)
end
def check_for_cancel
if params[:cancel_button]
redirect_to action: "index"
end
end
end

View file

@ -0,0 +1,82 @@
class Admin::BannersController < Admin::BaseController
# GET /admin/banners
def index
authorize(AdminBanner)
@admin_banners = AdminBanner.order("id DESC").paginate(page: params[:page])
end
# GET /admin/banners/1
def show
@admin_banner = authorize AdminBanner.find(params[:id])
end
# GET /admin/banners/new
def new
@admin_banner = authorize AdminBanner.new
end
# GET /admin/banners/1/edit
def edit
@admin_banner = authorize AdminBanner.find(params[:id])
end
# POST /admin/banners
def create
@admin_banner = authorize AdminBanner.new(admin_banner_params)
if @admin_banner.save
if @admin_banner.active?
AdminBanner.banner_on
flash[:notice] = ts('Setting banner back on for all users. This may take some time.')
else
flash[:notice] = ts('Banner successfully created.')
end
redirect_to @admin_banner
else
render action: 'new'
end
end
# PUT /admin/banners/1
def update
@admin_banner = authorize AdminBanner.find(params[:id])
if !@admin_banner.update(admin_banner_params)
render action: 'edit'
elsif params[:admin_banner_minor_edit]
flash[:notice] = ts('Updating banner for users who have not already dismissed it. This may take some time.')
redirect_to @admin_banner
else
if @admin_banner.active?
AdminBanner.banner_on
flash[:notice] = ts('Setting banner back on for all users. This may take some time.')
else
flash[:notice] = ts('Banner successfully updated.')
end
redirect_to @admin_banner
end
end
# GET /admin/banners/1/confirm_delete
def confirm_delete
@admin_banner = authorize AdminBanner.find(params[:id])
end
# DELETE /admin/banners/1
def destroy
@admin_banner = authorize AdminBanner.find(params[:id])
@admin_banner.destroy
flash[:notice] = ts('Banner successfully deleted.')
redirect_to admin_banners_path
end
private
def admin_banner_params
params.require(:admin_banner).permit(:content, :banner_type, :active)
end
end

View file

@ -0,0 +1,3 @@
class Admin::BaseController < ApplicationController
before_action :admin_only
end

View file

@ -0,0 +1,40 @@
class Admin::BlacklistedEmailsController < Admin::BaseController
def index
authorize AdminBlacklistedEmail
@admin_blacklisted_email = AdminBlacklistedEmail.new
if params[:query]
@admin_blacklisted_emails = AdminBlacklistedEmail.where(["email LIKE ?", '%' + params[:query] + '%'])
@admin_blacklisted_emails = @admin_blacklisted_emails.paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
end
@page_subtitle = t(".browser_title")
end
def create
@admin_blacklisted_email = authorize AdminBlacklistedEmail.new(admin_blacklisted_email_params)
@page_subtitle = t(".browser_title")
if @admin_blacklisted_email.save
flash[:notice] = ts("Email address %{email} banned.", email: @admin_blacklisted_email.email)
redirect_to admin_blacklisted_emails_path
else
render action: "index"
end
end
def destroy
@admin_blacklisted_email = authorize AdminBlacklistedEmail.find(params[:id])
@admin_blacklisted_email.destroy
flash[:notice] = ts("Email address %{email} removed from banned emails list.", email: @admin_blacklisted_email.email)
redirect_to admin_blacklisted_emails_path
end
private
def admin_blacklisted_email_params
params.require(:admin_blacklisted_email).permit(
:email
)
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Admin::PasswordsController < Devise::PasswordsController
before_action :user_logout_required
skip_before_action :store_location
layout "session"
end

View file

@ -0,0 +1,14 @@
# Namespaced Admin class
class Admin
# Handle admin session authentication
class SessionsController < Devise::SessionsController
before_action :user_logout_required, except: :destroy
skip_before_action :store_location, raise: false
# GET /admin/logout
def confirm_logout
# If the user is already logged out, we just redirect to the front page.
redirect_to root_path unless admin_signed_in?
end
end
end

View file

@ -0,0 +1,24 @@
class Admin::SettingsController < Admin::BaseController
before_action :load_admin_setting
def index
authorize @admin_setting
end
# PUT /admin_settings/1
def update
authorize @admin_setting
if @admin_setting.update(permitted_attributes(@admin_setting).merge(last_updated: current_admin))
flash[:notice] = t(".success")
redirect_to admin_settings_path
else
render :index
end
end
private
def load_admin_setting
@admin_setting = AdminSetting.first || AdminSetting.create(last_updated_by: Admin.first)
end
end

View file

@ -0,0 +1,98 @@
class Admin::SkinsController < Admin::BaseController
def index
authorize Skin
@unapproved_skins = Skin.unapproved_skins.sort_by_recent
end
def index_rejected
authorize Skin
@rejected_skins = Skin.rejected_skins.sort_by_recent
end
def index_approved
authorize Skin
@approved_skins = Skin.approved_skins.usable.sort_by_recent
end
def update
authorize Skin, :index?
flash[:notice] = []
modified_skin_titles = []
%w(official rejected cached featured in_chooser).each do |action|
skins_to_set = params["make_#{action}"] ? Skin.where(id: params["make_#{action}"].map {|id| id.to_i}) : []
skins_to_unset = params["make_un#{action}"] ? Skin.where(id: params["make_un#{action}"].map {|id| id.to_i}) : []
skins_to_set.each do |skin|
# Silently fail if the user doesn't have permission to update:
next unless policy(skin).update?
case action
when "official"
skin.update_attribute(:official, true)
when "rejected"
skin.update_attribute(:rejected, true)
when "cached"
next unless skin.official? && !skin.is_a?(WorkSkin)
skin.cache!
when "featured"
next unless skin.official? && !skin.is_a?(WorkSkin)
skin.cache! unless skin.cached?
skin.update_attribute(:featured, true)
when "in_chooser"
next unless skin.official? && !skin.is_a?(WorkSkin)
skin.cache! unless skin.cached?
skin.update_attribute(:in_chooser, true)
end
skin.update_attribute(:admin_note, params[:skin_admin_note]["#{skin.id}"]) if params[:skin_admin_note] && params[:skin_admin_note]["#{skin.id}"]
modified_skin_titles << skin.title
end
skins_to_unset.each do |skin|
# Silently fail if the user doesn't have permission to update:
next unless policy(skin).update?
case action
when "official"
skin.clear_cache! # no cache for unofficial skins
skin.update_attribute(:official, false)
skin.remove_me_from_preferences
when "rejected"
skin.update_attribute(:rejected, false)
when "cached"
next unless skin.official? && !skin.is_a?(WorkSkin)
skin.clear_cache! if skin.cached?
when "featured"
next unless skin.official? && !skin.is_a?(WorkSkin)
skin.update_attribute(:featured, false)
when "in_chooser"
next unless skin.official? && !skin.is_a?(WorkSkin)
skin.update_attribute(:in_chooser, false)
end
modified_skin_titles << skin.title
end
end
flash[:notice] << ts("The following skins were updated: %{titles}", titles: modified_skin_titles.join(', '))
# set default
if params[:set_default].present? && params[:set_default] != AdminSetting.default_skin&.title
authorize Skin, :set_default?
skin = Skin.find_by(title: params[:set_default], official: true)
@admin_setting = AdminSetting.first
if skin && @admin_setting
@admin_setting.default_skin = skin
@admin_setting.last_updated_by = params[:last_updated_by]
if @admin_setting.save
flash[:notice] << ts("Default skin changed to %{title}", title: skin.title)
else
flash[:error] = ts("We couldn't save the default skin change.")
end
end
end
redirect_to admin_skins_path
end
end

View file

@ -0,0 +1,36 @@
class Admin::SpamController < Admin::BaseController
def index
authorize ModeratedWork
conditions = case params[:show]
when "reviewed"
{ reviewed: true, approved: false }
when "approved"
{ approved: true }
else
{ reviewed: false, approved: false }
end
@works = ModeratedWork.where(conditions)
.joins(:work)
.includes(:work)
.order(:created_at)
.page(params[:page])
end
def bulk_update
authorize ModeratedWork
if ModeratedWork.bulk_update(spam_params)
flash[:notice] = "Works were successfully updated"
else
flash[:error] = "Sorry, please try again"
end
redirect_to admin_spam_index_path
end
private
def spam_params
params.slice(:spam, :ham)
end
end

View file

@ -0,0 +1,101 @@
class Admin::UserCreationsController < Admin::BaseController
before_action :get_creation, only: [:hide, :set_spam, :destroy]
before_action :can_be_marked_as_spam, only: [:set_spam]
def get_creation
raise "Redshirt: Attempted to constantize invalid class initialize #{params[:creation_type]}" unless %w(Bookmark ExternalWork Series Work).include?(params[:creation_type])
@creation_class = params[:creation_type].constantize
@creation = @creation_class.find(params[:id])
end
def can_be_marked_as_spam
unless @creation_class && @creation_class == Work
flash[:error] = ts("You can only mark works as spam currently.")
redirect_to polymorphic_path(@creation) and return
end
end
# Removes an object from public view
def hide
authorize @creation
@creation.hidden_by_admin = (params[:hidden] == "true")
@creation.save(validate: false)
action = @creation.hidden_by_admin? ? "hide" : "unhide"
AdminActivity.log_action(current_admin, @creation, action: action)
flash[:notice] = @creation.hidden_by_admin? ?
ts("Item has been hidden.") :
ts("Item is no longer hidden.")
if @creation_class == ExternalWork || @creation_class == Bookmark
redirect_to(request.env["HTTP_REFERER"] || root_path)
else
redirect_to polymorphic_path(@creation)
end
end
def set_spam
authorize @creation
action = "mark as " + (params[:spam] == "true" ? "spam" : "not spam")
AdminActivity.log_action(current_admin, @creation, action: action, summary: @creation.inspect)
if params[:spam] == "true"
unless @creation.hidden_by_admin
@creation.notify_of_hiding_for_spam if @creation_class == Work
@creation.hidden_by_admin = true
end
@creation.mark_as_spam!
flash[:notice] = ts("Work was marked as spam and hidden.")
else
@creation.mark_as_ham!
@creation.update_attribute(:hidden_by_admin, false)
flash[:notice] = ts("Work was marked not spam and unhidden.")
end
redirect_to polymorphic_path(@creation)
end
def destroy
authorize @creation
AdminActivity.log_action(current_admin, @creation, action: "destroy", summary: @creation.inspect)
@creation.destroy
flash[:notice] = ts("Item was successfully deleted.")
if @creation_class == Bookmark || @creation_class == ExternalWork
redirect_to bookmarks_path
else
redirect_to works_path
end
end
def confirm_remove_pseud
@work = authorize Work.find(params[:id])
@orphan_pseuds = @work.orphan_pseuds
return unless @orphan_pseuds.empty?
flash[:error] = t(".must_have_orphan_pseuds")
redirect_to work_path(@work) and return
end
def remove_pseud
@work = authorize Work.find(params[:id])
pseuds = params[:pseuds]
orphan_account = User.orphan_account
if pseuds.blank?
pseuds = @work.orphan_pseuds
if pseuds.length > 1
flash[:error] = t(".must_select_pseud")
redirect_to work_path(@work) and return
end
else
pseuds = Pseud.find(pseuds).select { |p| p.user_id == orphan_account.id }
end
orphan_pseud = orphan_account.default_pseud
pseuds.each do |pseud|
pseud.change_ownership(@work, orphan_pseud)
end
unless pseuds.empty?
AdminActivity.log_action(current_admin, @work, action: "remove orphan_account pseuds")
flash[:notice] = t(".success", pseuds: pseuds.map(&:byline).to_sentence, count: pseuds.length)
end
redirect_to work_path(@work)
end
end

View file

@ -0,0 +1,101 @@
class AdminPostsController < Admin::BaseController
before_action :admin_only, except: [:index, :show]
before_action :load_languages, except: [:show, :destroy]
# GET /admin_posts
def index
if params[:tag]
@tag = AdminPostTag.find_by(id: params[:tag])
if @tag
@admin_posts = @tag.admin_posts
end
end
@admin_posts ||= AdminPost
if params[:language_id].present? && (@language = Language.find_by(short: params[:language_id]))
@admin_posts = @admin_posts.where(language_id: @language.id)
@tags = AdminPostTag.distinct.joins(:admin_posts).where(admin_posts: { language_id: @language.id }).order(:name)
else
@admin_posts = @admin_posts.non_translated
@tags = AdminPostTag.order(:name)
end
@admin_posts = @admin_posts.order('created_at DESC').page(params[:page])
end
# GET /admin_posts/1
def show
admin_posts = AdminPost.non_translated
@admin_post = AdminPost.find_by(id: params[:id])
unless @admin_post
raise ActiveRecord::RecordNotFound, "Couldn't find admin post '#{params[:id]}'"
end
@admin_posts = admin_posts.order('created_at DESC').limit(8)
@previous_admin_post = admin_posts.order('created_at DESC').where('created_at < ?', @admin_post.created_at).first
@next_admin_post = admin_posts.order('created_at ASC').where('created_at > ?', @admin_post.created_at).first
@page_subtitle = @admin_post.title.html_safe
respond_to do |format|
format.html # show.html.erb
format.js
end
end
# GET /admin_posts/new
# GET /admin_posts/new.xml
def new
@admin_post = AdminPost.new
authorize @admin_post
end
# GET /admin_posts/1/edit
def edit
@admin_post = AdminPost.find(params[:id])
authorize @admin_post
end
# POST /admin_posts
def create
@admin_post = AdminPost.new(admin_post_params)
authorize @admin_post
if @admin_post.save
flash[:notice] = ts("Admin Post was successfully created.")
redirect_to(@admin_post)
else
render action: "new"
end
end
# PUT /admin_posts/1
def update
@admin_post = AdminPost.find(params[:id])
authorize @admin_post
if @admin_post.update(admin_post_params)
flash[:notice] = ts("Admin Post was successfully updated.")
redirect_to(@admin_post)
else
render action: "edit"
end
end
# DELETE /admin_posts/1
def destroy
@admin_post = AdminPost.find(params[:id])
authorize @admin_post
@admin_post.destroy
redirect_to(admin_posts_path)
end
protected
def load_languages
@news_languages = Language.where(id: Locale.all.map(&:language_id)).default_order
end
private
def admin_post_params
params.require(:admin_post).permit(
:admin_id, :title, :content, :translated_post_id, :language_id, :tag_list,
:comment_permissions, :moderated_commenting_enabled
)
end
end

View file

@ -0,0 +1,4 @@
class AdminsController < Admin::BaseController
def index
end
end

View file

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Api
# Version the API explicitly in the URL to allow different versions with breaking changes to co-exist if necessary.
# The roll over to the next number should happen when code written against the old version will not work
# with the new version.
module V2
class BaseController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :restrict_access
# Prevent unhandled errors from returning the normal HTML page
rescue_from StandardError, with: :render_standard_error_response
private
# Look for a token in the Authorization header only and check that the token isn't currently banned
def restrict_access
authenticate_or_request_with_http_token do |token, _|
ApiKey.exists?(access_token: token) && !ApiKey.find_by(access_token: token).banned?
end
end
# Top-level error handling: returns a 403 forbidden if a valid archivist isn't supplied and a 400
# if no works are supplied. If there is neither a valid archivist nor valid works, a 400 is returned
# with both errors as a message
def batch_errors(archivist, import_items)
errors = []
status = ""
if archivist&.is_archivist?
if import_items.nil? || import_items.empty?
status = :empty_request
errors << "No items to import were provided."
elsif import_items.size >= ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST
status = :too_many_request
errors << "This request contains too many items to import. A maximum of #{ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST} " \
"items can be imported at one time by an archivist."
end
else
status = :forbidden
errors << "The \"archivist\" field must specify the name of an Archive user with archivist privileges."
end
status = :ok if errors.empty?
[status, errors]
end
# Return a standard HTTP + Json envelope for all API responses
def render_api_response(status, messages, response = {})
# It's a bad request unless it's ok or an authorisation error
http_status = %i[forbidden ok].include?(status) ? status : :bad_request
render status: http_status, json: { status: status, messages: messages }.merge(response)
end
# Return a standard HTTP + Json envelope for errors that drop through other handling
def render_standard_error_response(exception)
message = "An error occurred in the Archive code: #{exception.message}"
render status: :internal_server_error, json: { status: :internal_server_error, messages: [message] }
end
end
end
end

View file

@ -0,0 +1,229 @@
class Api::V2::BookmarksController < Api::V2::BaseController
respond_to :json
# POST - search for bookmarks for this archivist
def search
archivist = User.find_by(login: params[:archivist])
bookmarks = params[:bookmarks]
# check for top-level errors (not an archivist, no bookmarks...)
status, messages = batch_errors(archivist, bookmarks)
results = []
if status == :ok
archivist_bookmarks = Bookmark.where(pseud_id: archivist.default_pseud.id)
results = bookmarks.map do |bookmark|
found_result = {}
found_result = check_archivist_bookmark(archivist, bookmark[:url], archivist_bookmarks) unless archivist_bookmarks.empty?
bookmark_response(
status: found_result[:bookmark_status] || :not_found,
bookmark_url: found_result[:bookmark_url] || "",
bookmark_id: bookmark[:id],
original_url: bookmark[:url],
messages: found_result[:bookmark_messages] || ["No bookmark found for archivist \"#{archivist.login}\" and URL \"#{bookmark[:url]}\""]
)
end
messages = ["Successfully searched bookmarks for archivist '#{archivist.login}'"]
end
render_api_response(status, messages, bookmarks: results)
end
# POST - create a bookmark for this archivist
def create
archivist = User.find_by(login: params[:archivist])
bookmarks = params[:bookmarks]
bookmarks_responses = []
@bookmarks = []
# check for top-level errors (not an archivist, no bookmarks...)
status, messages = batch_errors(archivist, bookmarks)
if status == :ok
# Flag error and successes
@some_errors = @some_success = false
# Process the bookmarks
archivist_bookmarks = Bookmark.where(pseud_id: archivist.default_pseud_id, bookmarkable_type: "ExternalWork")
bookmarks.each do |bookmark|
bookmarks_responses << create_bookmark(archivist, bookmark, archivist_bookmarks)
end
# set final response code and message depending on the flags
status = :bad_request if bookmarks_responses.any? { |r| [:ok, :created, :found].exclude?(r[:status]) }
messages = response_message(messages)
end
render_api_response(status, messages, bookmarks: bookmarks_responses)
end
private
# Find bookmarks for this archivist
def check_archivist_bookmark(archivist, current_bookmark_url, archivist_bookmarks)
archivist_bookmarks = archivist_bookmarks
.select { |b| b&.bookmarkable.is_a?(ExternalWork) ? b&.bookmarkable&.url == current_bookmark_url : false }
.map { |b| [b, b.bookmarkable] }
if archivist_bookmarks.present?
archivist_bookmark, archivist_bookmarkable = archivist_bookmarks.first
find_bookmark_response(
bookmarkable: archivist_bookmarkable,
bookmark_status: :found,
bookmark_message: "There is already a bookmark for #{archivist.login} and the URL #{current_bookmark_url}",
bookmark_url: bookmark_url(archivist_bookmark)
)
else
find_bookmark_response(
bookmarkable: nil,
bookmark_status: :not_found,
bookmark_message: "There is no bookmark for #{archivist.login} and the URL #{current_bookmark_url}",
bookmark_url: ""
)
end
end
# Create a bookmark for this archivist using the Bookmark model
def create_bookmark(archivist, params, archivist_bookmarks)
found_result = {}
bookmark_attributes = bookmark_attributes(archivist, params)
external_work_attributes = external_work_attributes(params)
bookmark_status, bookmark_messages = external_work_errors(external_work_attributes)
bookmark_url = nil
original_url = nil
bookmarkable = nil
@some_errors = true
if bookmark_status == :ok
begin
# Check if this bookmark is already imported by filtering the archivist's bookmarks
unless archivist_bookmarks.empty?
found_result = check_archivist_bookmark(archivist, external_work_attributes[:url], archivist_bookmarks)
bookmarkable = found_result[:bookmarkable]
end
if found_result[:bookmark_status] == :found
found_result[:bookmark_status] = :already_imported
else
bookmarkable = ExternalWork.new(external_work_attributes)
bookmark = bookmarkable.bookmarks.build(bookmark_attributes)
if bookmarkable.save && bookmark.save
@bookmarks << bookmark
@some_success = true
@some_errors = false
bookmark_status = :created
bookmark_url = bookmark_url(bookmark)
bookmark_messages << "Successfully created bookmark for \"" + bookmarkable.title + "\"."
else
bookmark_status = :unprocessable_entity
bookmark_messages << bookmarkable.errors.full_messages + bookmark.errors.full_messages
end
end
rescue StandardError => exception
bookmark_status = :unprocessable_entity
bookmark_messages << exception.message
end
original_url = bookmarkable.url if bookmarkable
end
bookmark_response(
status: bookmark_status || found_result[:bookmark_status],
bookmark_url: bookmark_url || found_result[:bookmark_url],
bookmark_id: params[:id],
original_url: original_url,
messages: bookmark_messages.flatten || found_result[:bookmark_messages]
)
end
# Error handling
# Set messages based on success and error flags
def response_message(messages)
messages << if @some_success && @some_errors
"At least one bookmark was not created. Please check the individual bookmark results for further information."
elsif !@some_success && @some_errors
"None of the bookmarks were created. Please check the individual bookmark results for further information."
else
"All bookmarks were successfully created."
end
messages
end
# Handling for incomplete requests
def external_work_errors(external_work_attributes)
status = :bad_request
errors = []
# Perform basic validation which the ExternalWork model doesn't do or returns strange messages for
# (title is validated correctly in the model and so isn't checked here)
url = external_work_attributes[:url]
author = external_work_attributes[:author]
fandom = external_work_attributes[:fandom_string]
if url.nil?
# Unreachable and AO3 URLs are handled in the ExternalWork model
errors << "This bookmark does not contain a URL to an external site. Please specify a valid, non-AO3 URL."
end
if author.nil? || author == ""
errors << "This bookmark does not contain an external author name. Please specify an author."
end
if fandom.nil? || fandom == ""
errors << "This bookmark does not contain a fandom. Please specify a fandom."
end
status = :ok if errors.empty?
[status, errors]
end
# Request and response hashes
# Map JSON request to attributes for bookmark
def bookmark_attributes(archivist, params)
{
pseud_id: archivist.default_pseud_id,
bookmarker_notes: params[:bookmarker_notes],
tag_string: params[:tag_string] || "",
collection_names: params[:collection_names],
private: params[:private].blank? ? false : params[:private],
rec: params[:recommendation].blank? ? false : params[:recommendation]
}
end
# Map JSON request to attributes for external work
def external_work_attributes(params)
{
url: params[:url],
author: params[:author],
title: params[:title],
summary: params[:summary],
fandom_string: params[:fandom_string] || "",
rating_string: params[:rating_string] || "",
category_string: params[:category_string] ? params[:category_string].to_s.split(",") : [], # category is actually an array on bookmarks
relationship_string: params[:relationship_string] || "",
character_string: params[:character_string] || ""
}
end
def bookmark_response(status:, bookmark_url:, bookmark_id:, original_url:, messages:)
messages = [messages] unless messages.respond_to?('each')
{
status: status,
archive_url: bookmark_url,
original_id: bookmark_id,
original_url: original_url,
messages: messages
}
end
def find_bookmark_response(bookmarkable:, bookmark_status:, bookmark_message:, bookmark_url:)
bookmark_status = :not_found unless [:found, :not_found].include?(bookmark_status)
{
bookmarkable: bookmarkable,
bookmark_status: bookmark_status,
bookmark_messages: bookmark_message,
bookmark_url: bookmark_url
}
end
end

View file

@ -0,0 +1,225 @@
class Api::V2::WorksController < Api::V2::BaseController
respond_to :json
# POST - search for works based on imported url
def search
works = params[:works]
original_urls = works.map { |w| w[:original_urls] }.flatten
results = []
messages = []
if original_urls.nil? || original_urls.blank? || original_urls.empty?
status = :empty_request
messages << "Please provide a list of URLs to find."
elsif original_urls.size >= ArchiveConfig.IMPORT_MAX_CHAPTERS
status = :too_many_request
messages << "Please provide no more than #{ArchiveConfig.IMPORT_MAX_CHAPTERS} URLs to find."
else
status = :ok
results = find_existing_works(original_urls)
messages << "Successfully searched all provided URLs."
end
render_api_response(status, messages, works: results)
end
# POST - create a work and invite authors to claim
def create
archivist = User.find_by(login: params[:archivist])
external_works = params[:items] || params[:works]
works_responses = []
# check for top-level errors (not an archivist, no works...)
status, messages = batch_errors(archivist, external_works)
if status == :ok
# Process the works, updating the flags
works_responses = external_works.map { |external_work| import_work(archivist, external_work.merge(params.permit!)) }
success_responses, error_responses = works_responses.partition { |r| [:ok, :created, :found].include?(r[:status]) }
# Send claim notification emails for successful works
if params[:send_claim_emails] && success_responses.present?
notified_authors = notify_and_return_authors(success_responses.map { |r| r[:work] }, archivist)
messages << "Claim emails sent to #{notified_authors.to_sentence}."
end
# set final status code and message depending on the flags
status = :bad_request if error_responses.present?
messages = response_message(messages, success_responses.present?, error_responses.present?)
end
render_api_response(status, messages, works: works_responses.map { |r| r.except!(:work) })
end
private
# Set messages based on success and error flags
def response_message(messages, any_success, any_errors)
messages << if any_success && any_errors
"At least one work was not imported. Please check individual work responses for further information."
elsif !any_success && any_errors
"None of the works were imported. Please check individual work responses for further information."
else
"All works were successfully imported."
end
messages
end
# Work-level error handling for requests that are incomplete or too large
def work_errors(work)
status = :bad_request
errors = []
urls = work[:chapter_urls]
if urls.nil? || urls.empty?
status = :empty_request
errors << "This work doesn't contain any chapter URLs. " +
"Works can only be imported from publicly-accessible chapter URLs."
elsif urls.length >= ArchiveConfig.IMPORT_MAX_CHAPTERS
status = :too_many_requests
errors << "This work contains too many chapter URLs. A maximum of #{ArchiveConfig.IMPORT_MAX_CHAPTERS} " \
"chapters can be imported per work."
end
status = :ok if errors.empty?
[status, errors]
end
# Search for works imported from the provided URLs
def find_existing_works(original_urls)
results = []
messages = []
original_urls.each do |original|
original_id = ""
if original.class == String
original_url = original
else
original_id = original[:id]
original_url = original[:url]
end
# Search for works - there may be duplicates
search_results = find_work_by_import_url(original_url)
if search_results[:works].empty?
results << { status: :not_found,
original_id: original_id,
original_url: original_url,
messages: [search_results[:message]] }
else
work_results = search_results[:works].map do |work|
archive_url = work_url(work)
message = "Work \"#{work.title}\", created on #{work.created_at.to_date.to_fs(:iso_date)} was found at \"#{archive_url}\"."
messages << message
{ archive_url: archive_url,
created: work.created_at,
message: message }
end
results << { status: :found,
original_id: original_id,
original_url: original_url,
search_results: work_results,
messages: messages
}
end
end
results
end
def find_work_by_import_url(original_url)
works = nil
if original_url.blank?
message = "Please provide the original URL for the work."
else
# We know the url will be identical no need for a call to find_by_url
works = Work.where(imported_from_url: original_url)
message = if works.empty?
"No work has been imported from \"" + original_url + "\"."
else
"Found #{works.size} work(s) imported from \"" + original_url + "\"."
end
end
{
original_url: original_url,
works: works,
message: message
}
end
# Use the story parser to scrape works from the chapter URLs
def import_work(archivist, external_work)
work_status, work_messages = work_errors(external_work)
work_url = ""
original_url = ""
if work_status == :ok
urls = external_work[:chapter_urls]
original_url = urls.first
storyparser = StoryParser.new
options = story_parser_options(archivist, external_work)
begin
response = storyparser.import_chapters_into_story(urls, options)
work = response[:work]
work_status = response[:status]
work.save if work_status == :created
work_url = work_url(work)
work_messages << response[:message]
rescue => exception
Rails.logger.error("------- API v2: error: #{exception.inspect}")
work_status = :unprocessable_entity
work_messages << "Unable to import this work."
work_messages << exception.message
end
end
{
status: work_status,
archive_url: work_url,
original_id: external_work[:id],
original_url: original_url,
messages: work_messages,
work: work
}
end
# Send invitations to external authors for a given set of works
def notify_and_return_authors(success_works, archivist)
notified_authors = []
external_authors = success_works.map(&:external_authors).flatten.uniq
external_authors&.each do |external_author|
external_author.find_or_invite(archivist)
# One of the external author pseuds is its email address so filter that one out
author_names = external_author.names.reject { |a| a.name == external_author.email }.map(&:name).flatten.join(", ")
notified_authors << author_names
end
notified_authors
end
# Request and response hashes
# Create options map for StoryParser
def story_parser_options(archivist, work_params)
{
archivist: archivist,
import_multiple: "chapters",
importing_for_others: true,
do_not_set_current_author: true,
post_without_preview: work_params[:post_without_preview].blank? ? true : work_params[:post_without_preview],
restricted: work_params[:restricted],
override_tags: work_params[:override_tags].nil? ? true : work_params[:override_tags],
detect_tags: work_params[:detect_tags].nil? ? true : work_params[:detect_tags],
collection_names: work_params[:collection_names],
title: work_params[:title],
fandom: work_params[:fandoms],
archive_warning: work_params[:warnings],
character: work_params[:characters],
rating: work_params[:rating],
relationship: work_params[:relationships],
category: work_params[:categories],
freeform: work_params[:additional_tags],
language_id: Language.find_by(short: work_params[:language_code])&.id,
summary: work_params[:summary],
notes: work_params[:notes],
encoding: work_params[:encoding],
external_author_name: work_params[:external_author_name],
external_author_email: work_params[:external_author_email],
external_coauthor_name: work_params[:external_coauthor_name],
external_coauthor_email: work_params[:external_coauthor_email]
}
end
end

View file

@ -0,0 +1,564 @@
class ApplicationController < ActionController::Base
include ActiveStorage::SetCurrent
include Pundit::Authorization
protect_from_forgery with: :exception, prepend: true
rescue_from ActionController::InvalidAuthenticityToken, with: :display_auth_error
rescue_from Pundit::NotAuthorizedError do
admin_only_access_denied
end
# sets admin user for pundit policies
def pundit_user
current_admin
end
rescue_from ActionController::UnknownFormat, with: :raise_not_found
rescue_from Elastic::Transport::Transport::Errors::ServiceUnavailable do
# Non-standard code to distinguish Elasticsearch errors from standard 503s.
# We can't use 444 because nginx will close connections without sending
# response headers.
head 445
end
def raise_not_found
redirect_to '/404'
end
rescue_from Rack::Timeout::RequestTimeoutException, with: :raise_timeout
def raise_timeout
redirect_to timeout_error_path
end
helper :all # include all helpers, all the time
include HtmlCleaner
before_action :sanitize_ac_params
# sanitize_params works best with a hash, and will convert
# ActionController::Parameters to a hash in order to work with them anyway.
#
# Controllers need to deal with ActionController::Parameters, not hashes.
# These methods hand the params as a hash to sanitize_params, and then
# transforms the results back into ActionController::Parameters.
def sanitize_ac_params
sanitize_params(params.to_unsafe_h).each do |key, value|
params[key] = transform_sanitized_hash_to_ac_params(key, value)
end
end
include Pagy::Backend
def pagy(collection, **vars)
pagy_overflow_handler do
super
end
end
def pagy_query_result(query_result, **vars)
pagy_overflow_handler do
Pagy.new(
count: query_result.total_entries,
page: query_result.current_page,
limit: query_result.per_page,
**vars
)
end
end
def pagy_overflow_handler(*)
yield
rescue Pagy::OverflowError
nil
end
def display_auth_error
respond_to do |format|
format.html do
redirect_to auth_error_path
end
format.any(:js, :json) do
render json: {
errors: {
auth_error: "Your current session has expired and we can't authenticate your request. Try logging in again, refreshing the page, or <a href='http://kb.iu.edu/data/ahic.html'>clearing your cache</a> if you continue to experience problems.".html_safe
}
}, status: :unprocessable_entity
end
end
end
def transform_sanitized_hash_to_ac_params(key, value)
if value.is_a?(Hash)
ActionController::Parameters.new(value)
elsif value.is_a?(Array)
value.map.with_index do |val, index|
value[index] = transform_sanitized_hash_to_ac_params(key, val)
end
else
value
end
end
helper_method :current_user
helper_method :current_admin
helper_method :logged_in?
helper_method :logged_in_as_admin?
helper_method :guest?
# Title helpers
helper_method :process_title
# clear out the flash-being-set
before_action :clear_flash_cookie
def clear_flash_cookie
cookies.delete(:flash_is_set)
end
after_action :check_for_flash
def check_for_flash
cookies[:flash_is_set] = 1 unless flash.empty?
end
# Override redirect_to so that if it's called in a before_action hook, it'll
# still call check_for_flash after it runs.
def redirect_to(*args, **kwargs)
super.tap do
check_for_flash
end
end
after_action :ensure_admin_credentials
def ensure_admin_credentials
if logged_in_as_admin?
# if we are logged in as an admin and we don't have the admin_credentials
# set then set that cookie
cookies[:admin_credentials] = { value: 1, expires: 1.year.from_now } unless cookies[:admin_credentials]
else
# if we are NOT logged in as an admin and we have the admin_credentials
# set then delete that cookie
cookies.delete :admin_credentials unless cookies[:admin_credentials].nil?
end
end
# If there is no user_credentials cookie and the user appears to be logged in,
# redirect to the lost cookie page. Needs to be before the code to fix
# the user_credentials cookie or it won't fire.
before_action :logout_if_not_user_credentials
def logout_if_not_user_credentials
if logged_in? && cookies[:user_credentials].nil? && controller_name != "sessions"
logger.error "Forcing logout"
sign_out
redirect_to '/lost_cookie' and return
end
end
# The user_credentials cookie is used by nginx to figure out whether or not
# to cache the page, so we want to make sure that it's set when the user is
# logged in, and cleared when the user is logged out.
after_action :ensure_user_credentials
def ensure_user_credentials
if logged_in?
cookies[:user_credentials] = { value: 1, expires: 1.year.from_now } unless cookies[:user_credentials]
else
cookies.delete :user_credentials unless cookies[:user_credentials].nil?
end
end
protected
def logged_in?
user_signed_in?
end
def logged_in_as_admin?
admin_signed_in?
end
def guest?
!(logged_in? || logged_in_as_admin?)
end
def process_title(string)
string = string.humanize.titleize
string = string.sub("Faq", "FAQ")
string = string.sub("Tos", "TOS")
string = string.sub("Dmca", "DMCA")
return string
end
public
before_action :load_admin_banner
def load_admin_banner
if Rails.env.development?
@admin_banner = AdminBanner.where(active: true).last
else
# http://stackoverflow.com/questions/12891790/will-returning-a-nil-value-from-a-block-passed-to-rails-cache-fetch-clear-it
# Basically we need to store a nil separately.
@admin_banner = Rails.cache.fetch("admin_banner") do
banner = AdminBanner.where(active: true).last
banner.nil? ? "" : banner
end
@admin_banner = nil if @admin_banner == ""
end
end
before_action :load_tos_popup
def load_tos_popup
# Integers only, YYYY-MM-DD format of date Board approved TOS
@current_tos_version = 2024_11_19 # rubocop:disable Style/NumericLiterals
end
# store previous page in session to make redirecting back possible
# if already redirected once, don't redirect again.
before_action :store_location
def store_location
if session[:return_to] == "redirected"
session.delete(:return_to)
elsif request.fullpath.length > 200
# Sessions are stored in cookies, which has a 4KB size limit.
# Don't store paths that are too long (e.g. filters with lots of exclusions).
# Also remove the previous stored path.
session.delete(:return_to)
else
session[:return_to] = request.fullpath
end
end
# Redirect to the URI stored by the most recent store_location call or
# to the passed default.
def redirect_back_or_default(default = root_path)
back = session[:return_to]
session.delete(:return_to)
if back
session[:return_to] = "redirected"
redirect_to(back) and return
else
redirect_to(default) and return
end
end
def after_sign_in_path_for(resource)
if resource.is_a?(Admin)
admins_path
else
back = session[:return_to]
session.delete(:return_to)
back || user_path(current_user)
end
end
def authenticate_admin!
if admin_signed_in?
super
else
redirect_to root_path, notice: "I'm sorry, only an admin can look at that area"
## if you want render 404 page
## render file: File.join(Rails.root, 'public/404'), formats: [:html], status: 404, layout: false
end
end
# Filter method - keeps users out of admin areas
def admin_only
authenticate_admin! || admin_only_access_denied
end
# Filter method to prevent admin users from accessing certain actions
def users_only
logged_in? || access_denied
end
# Filter method - requires user to have opendoors privs
def opendoors_only
(logged_in? && permit?("opendoors")) || access_denied
end
# Redirect as appropriate when an access request fails.
#
# The default action is to redirect to the login screen.
#
# Override this method in your controllers if you want to have special
# behavior in case the user is not authorized
# to access the requested action. For example, a popup window might
# simply close itself.
def access_denied(options ={})
store_location
if logged_in?
destination = options[:redirect].blank? ? user_path(current_user) : options[:redirect]
# i18n-tasks-use t('users.reconfirm_email.access_denied.logged_in')
flash[:error] = t(".access_denied.logged_in", default: t("application.access_denied.access_denied.logged_in")) # rubocop:disable I18n/DefaultTranslation
redirect_to destination
else
destination = options[:redirect].blank? ? new_user_session_path : options[:redirect]
flash[:error] = ts "Sorry, you don't have permission to access the page you were trying to reach. Please log in."
redirect_to destination
end
false
end
def admin_only_access_denied
respond_to do |format|
format.html do
flash[:error] = t("admin.access.page_access_denied")
redirect_to root_path
end
format.json do
errors = [t("admin.access.action_access_denied")]
render json: { errors: errors }, status: :forbidden
end
format.js do
flash[:error] = t("admin.access.page_access_denied")
render js: "window.location.href = '#{root_path}';"
end
end
end
# Filter method - prevents users from logging in as admin
def user_logout_required
if logged_in?
flash[:notice] = 'Please log out of your user account first!'
redirect_to root_path
end
end
# Prevents admin from logging in as users
def admin_logout_required
if logged_in_as_admin?
flash[:notice] = 'Please log out of your admin account first!'
redirect_to root_path
end
end
# Hide admin banner via cookies
before_action :hide_banner
def hide_banner
if params[:hide_banner]
session[:hide_banner] = true
end
end
# Store the current user as a class variable in the User class,
# so other models can access it with "User.current_user"
around_action :set_current_user
def set_current_user
User.current_user = logged_in_as_admin? ? current_admin : current_user
@current_user = current_user
unless current_user.nil?
@current_user_subscriptions_count, @current_user_visible_work_count, @current_user_bookmarks_count, @current_user_owned_collections_count, @current_user_challenge_signups_count, @current_user_offer_assignments, @current_user_unposted_works_size=
Rails.cache.fetch("user_menu_counts_#{current_user.id}",
expires_in: 2.hours,
race_condition_ttl: 5) { "#{current_user.subscriptions.count}, #{current_user.visible_work_count}, #{current_user.bookmarks.count}, #{current_user.owned_collections.count}, #{current_user.challenge_signups.count}, #{current_user.offer_assignments.undefaulted.count + current_user.pinch_hit_assignments.undefaulted.count}, #{current_user.unposted_works.size}" }.split(",").map(&:to_i)
end
yield
User.current_user = nil
@current_user = nil
end
def load_collection
@collection = Collection.find_by(name: params[:collection_id]) if params[:collection_id]
end
def collection_maintainers_only
logged_in? && @collection && @collection.user_is_maintainer?(current_user) || access_denied
end
def collection_owners_only
logged_in? && @collection && @collection.user_is_owner?(current_user) || access_denied
end
def not_allowed(fallback=nil)
flash[:error] = ts("Sorry, you're not allowed to do that.")
redirect_to (fallback || root_path) rescue redirect_to '/'
end
def get_page_title(fandom, author, title, options = {})
# truncate any piece that is over 15 chars long to the nearest word
if options[:truncate]
fandom = fandom.gsub(/^(.{15}[\w.]*)(.*)/) {$2.empty? ? $1 : $1 + '...'}
author = author.gsub(/^(.{15}[\w.]*)(.*)/) {$2.empty? ? $1 : $1 + '...'}
title = title.gsub(/^(.{15}[\w.]*)(.*)/) {$2.empty? ? $1 : $1 + '...'}
end
@page_title = ""
if logged_in? && !current_user.preference.try(:work_title_format).blank?
@page_title = current_user.preference.work_title_format.dup
@page_title.gsub!(/FANDOM/, fandom)
@page_title.gsub!(/AUTHOR/, author)
@page_title.gsub!(/TITLE/, title)
else
@page_title = title + " - " + author + " - " + fandom
end
@page_title += " [#{ArchiveConfig.APP_NAME}]" unless options[:omit_archive_name]
@page_title.html_safe
end
public
#### -- AUTHORIZATION -- ####
# It is just much easier to do this here than to try to stuff variable values into a constant in environment.rb
def is_registered_user?
logged_in? || logged_in_as_admin?
end
def is_admin?
logged_in_as_admin?
end
def see_adult?
params[:anchor] = "comments" if (params[:show_comments] && params[:anchor].blank?)
return true if cookies[:view_adult] || logged_in_as_admin?
return false unless current_user
return true if current_user.is_author_of?(@work)
return true if current_user.preference && current_user.preference.adult
return false
end
def use_caching?
%w(staging production test).include?(Rails.env) && AdminSetting.current.enable_test_caching?
end
protected
# Prevents banned and suspended users from adding/editing content
def check_user_status
if current_user.is_a?(User) && (current_user.suspended? || current_user.banned?)
if current_user.suspended?
suspension_end = current_user.suspended_until
# Unban threshold is 6:51pm, 12 hours after the unsuspend_users rake task located in schedule.rb is run at 6:51am
unban_theshold = DateTime.new(suspension_end.year, suspension_end.month, suspension_end.day, 18, 51, 0, "+00:00")
# If the stated suspension end date is after the unban threshold we need to advance a day
suspension_end = suspension_end.next_day(1) if suspension_end > unban_theshold
localized_suspension_end = view_context.date_in_zone(suspension_end)
flash[:error] = t("users.status.suspension_notice_html", suspended_until: localized_suspension_end, contact_abuse_link: view_context.link_to(t("users.contact_abuse"), new_abuse_report_path))
else
flash[:error] = t("users.status.ban_notice_html", contact_abuse_link: view_context.link_to(t("users.contact_abuse"), new_abuse_report_path))
end
redirect_to current_user
end
end
# Prevents temporarily suspended users from deleting content
def check_user_not_suspended
return unless current_user.is_a?(User) && current_user.suspended?
suspension_end = current_user.suspended_until
# Unban threshold is 6:51pm, 12 hours after the unsuspend_users rake task located in schedule.rb is run at 6:51am
unban_theshold = DateTime.new(suspension_end.year, suspension_end.month, suspension_end.day, 18, 51, 0, "+00:00")
# If the stated suspension end date is after the unban threshold we need to advance a day
suspension_end = suspension_end.next_day(1) if suspension_end > unban_theshold
localized_suspension_end = view_context.date_in_zone(suspension_end)
flash[:error] = t("users.status.suspension_notice_html", suspended_until: localized_suspension_end, contact_abuse_link: view_context.link_to(t("users.contact_abuse"), new_abuse_report_path))
redirect_to current_user
end
# Does the current user own a specific object?
def current_user_owns?(item)
!item.nil? && current_user.is_a?(User) && (item.is_a?(User) ? current_user == item : current_user.is_author_of?(item))
end
# Make sure a specific object belongs to the current user and that they have permission
# to view, edit or delete it
def check_ownership
access_denied(redirect: @check_ownership_of) unless current_user_owns?(@check_ownership_of)
end
def check_ownership_or_admin
return true if logged_in_as_admin?
access_denied(redirect: @check_ownership_of) unless current_user_owns?(@check_ownership_of)
end
# Make sure the user is allowed to see a specific page
# includes a special case for restricted works and series, since we want to encourage people to sign up to read them
def check_visibility
if @check_visibility_of.respond_to?(:restricted) && @check_visibility_of.restricted && User.current_user.nil?
redirect_to new_user_session_path(restricted: true)
elsif @check_visibility_of.is_a? Skin
access_denied unless logged_in_as_admin? || current_user_owns?(@check_visibility_of) || @check_visibility_of.official?
else
is_hidden = (@check_visibility_of.respond_to?(:visible) && !@check_visibility_of.visible) ||
(@check_visibility_of.respond_to?(:visible?) && !@check_visibility_of.visible?) ||
(@check_visibility_of.respond_to?(:hidden_by_admin?) && @check_visibility_of.hidden_by_admin?)
can_view_hidden = logged_in_as_admin? || current_user_owns?(@check_visibility_of)
access_denied if (is_hidden && !can_view_hidden)
end
end
# Make sure user is allowed to access tag wrangling pages
def check_permission_to_wrangle
if AdminSetting.current.tag_wrangling_off? && !logged_in_as_admin?
flash[:error] = "Wrangling is disabled at the moment. Please check back later."
redirect_to root_path
else
logged_in_as_admin? || permit?("tag_wrangler") || access_denied
end
end
# Checks if user is allowed to see related page if parent item is hidden or in unrevealed collection
# Checks if user is logged in if parent item is restricted
def check_visibility_for(parent)
return if logged_in_as_admin? || current_user_owns?(parent) # Admins and the owner can see all related pages
access_denied(redirect: root_path) if parent.try(:hidden_by_admin) || parent.try(:in_unrevealed_collection) || (parent.respond_to?(:visible?) && !parent.visible?)
end
public
def valid_sort_column(param, model='work')
allowed = []
if model.to_s.downcase == 'work'
allowed = %w(author title date created_at word_count hit_count)
elsif model.to_s.downcase == 'tag'
allowed = %w[name created_at taggings_count_cache uses]
elsif model.to_s.downcase == 'collection'
allowed = %w(collections.title collections.created_at)
elsif model.to_s.downcase == 'prompt'
allowed = %w(fandom created_at prompter)
elsif model.to_s.downcase == 'claim'
allowed = %w(created_at claimer)
end
!param.blank? && allowed.include?(param.to_s.downcase)
end
def set_sort_order
# sorting
@sort_column = (valid_sort_column(params[:sort_column],"prompt") ? params[:sort_column] : 'id')
@sort_direction = (valid_sort_direction(params[:sort_direction]) ? params[:sort_direction] : 'DESC')
if !params[:sort_direction].blank? && !valid_sort_direction(params[:sort_direction])
params[:sort_direction] = 'DESC'
end
@sort_order = @sort_column + " " + @sort_direction
end
def valid_sort_direction(param)
!param.blank? && %w(asc desc).include?(param.to_s.downcase)
end
def flash_search_warnings(result)
if result.respond_to?(:error) && result.error
flash.now[:error] = result.error
elsif result.respond_to?(:notice) && result.notice
flash.now[:notice] = result.notice
end
end
# Don't get unnecessary data for json requests
skip_before_action :load_admin_banner,
:store_location,
if: proc { %w(js json).include?(request.format) }
end

View file

@ -0,0 +1,195 @@
class ArchiveFaqsController < ApplicationController
before_action :admin_only, except: [:index, :show]
before_action :set_locale
before_action :validate_locale, if: :logged_in_as_admin?
before_action :require_language_id
before_action :default_locale_only, only: [:new, :create, :manage, :update_positions, :confirm_delete, :destroy]
around_action :with_locale
# GET /archive_faqs
def index
@archive_faqs = ArchiveFaq.order("position ASC")
unless logged_in_as_admin?
@archive_faqs = @archive_faqs.with_translations(I18n.locale)
end
respond_to do |format|
format.html # index.html.erb
end
end
# GET /archive_faqs/1
def show
@questions = []
@archive_faq = ArchiveFaq.find_by!(slug: params[:id])
if params[:language_id] == "en"
@questions = @archive_faq.questions
else
@archive_faq.questions.each do |question|
question.translations.each do |translation|
if translation.is_translated == "1" && params[:language_id].to_s == translation.locale.to_s
@questions << question
end
end
end
end
@page_subtitle = @archive_faq.title + ts(" FAQ")
respond_to do |format|
format.html # show.html.erb
end
end
protected
def build_questions
notice = ""
num_to_build = params["num_questions"] ? params["num_questions"].to_i : @archive_faq.questions.count
if num_to_build < @archive_faq.questions.count
notice += ts("There are currently %{num} questions. You can only submit a number equal to or greater than %{num}. ", num: @archive_faq.questions.count)
num_to_build = @archive_faq.questions.count
elsif params["num_questions"]
notice += ts("Set up %{num} questions. ", num: num_to_build)
end
num_existing = @archive_faq.questions.count
num_existing.upto(num_to_build-1) do
@archive_faq.questions.build
end
unless notice.blank?
flash[:notice] = notice
end
end
public
# GET /archive_faqs/new
def new
@archive_faq = authorize ArchiveFaq.new
1.times { @archive_faq.questions.build(attributes: { question: "This is a temporary question", content: "This is temporary content", anchor: "ThisIsATemporaryAnchor"})}
respond_to do |format|
format.html # new.html.erb
end
end
# GET /archive_faqs/1/edit
def edit
@archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
authorize :archive_faq, :full_access? if default_locale?
build_questions
end
# GET /archive_faqs/manage
def manage
@archive_faqs = authorize ArchiveFaq.order("position ASC")
end
# POST /archive_faqs
def create
@archive_faq = authorize ArchiveFaq.new(archive_faq_params)
if @archive_faq.save
flash[:notice] = t(".success")
redirect_to(@archive_faq)
else
render action: "new"
end
end
# PUT /archive_faqs/1
def update
@archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
authorize :archive_faq, :full_access? if default_locale?
if @archive_faq.update(archive_faq_params)
flash[:notice] = t(".success")
redirect_to(@archive_faq)
else
render action: "edit"
end
end
# reorder FAQs
def update_positions
authorize :archive_faq
if params[:archive_faqs]
@archive_faqs = ArchiveFaq.reorder_list(params[:archive_faqs])
flash[:notice] = t(".success")
elsif params[:archive_faq]
params[:archive_faq].each_with_index do |id, position|
ArchiveFaq.update(id, position: position + 1)
(@archive_faqs ||= []) << ArchiveFaq.find(id)
end
end
respond_to do |format|
format.html { redirect_to(archive_faqs_path) }
format.js { render nothing: true }
end
end
# The ?language_id=somelanguage needs to persist throughout URL changes
# Get the value from set_locale to make sure there's no problem with order
def default_url_options
{ language_id: set_locale.to_s }
end
# Set the locale as an instance variable first
def set_locale
session[:language_id] = params[:language_id] if Locale.exists?(iso: params[:language_id])
if current_user.present?
@i18n_locale = session[:language_id].presence || current_user.preference.locale.iso
else
@i18n_locale = session[:language_id].presence || I18n.default_locale
end
end
def validate_locale
return if params[:language_id].blank? || Locale.exists?(iso: params[:language_id])
flash[:error] = "The specified locale does not exist."
redirect_to url_for(request.query_parameters.merge(language_id: I18n.default_locale))
end
def require_language_id
return if params[:language_id].present? && Locale.exists?(iso: params[:language_id])
redirect_to url_for(request.query_parameters.merge(language_id: @i18n_locale.to_s))
end
def default_locale_only
return if default_locale?
flash[:error] = t("archive_faqs.default_locale_only")
redirect_to archive_faqs_path
end
# Setting I18n.locale directly is not thread safe
def with_locale
I18n.with_locale(@i18n_locale) { yield }
end
# GET /archive_faqs/1/confirm_delete
def confirm_delete
@archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
end
# DELETE /archive_faqs/1
def destroy
@archive_faq = authorize ArchiveFaq.find_by(slug: params[:id])
@archive_faq.destroy
redirect_to(archive_faqs_path)
end
private
def default_locale?
@i18n_locale.to_s == I18n.default_locale.to_s
end
def archive_faq_params
params.require(:archive_faq).permit(
:title,
questions_attributes: [
:id, :question, :anchor, :content, :screencast, :_destroy, :is_translated
]
)
end
end

View file

@ -0,0 +1,224 @@
class AutocompleteController < ApplicationController
respond_to :json
skip_before_action :store_location
skip_around_action :set_current_user, except: [:collection_parent_name, :owned_tag_sets, :site_skins]
skip_before_action :sanitize_ac_params # can we dare!
#### DO WE NEED THIS AT ALL? IF IT FIRES WITHOUT A TERM AND 500s BECAUSE USER DID SOMETHING WACKY SO WHAT
# # If you have an autocomplete that should fire without a term add it here
# before_action :require_term, except: [:tag_in_fandom, :relationship_in_fandom, :character_in_fandom, :nominated_parents]
#
# def require_term
# if params[:term].blank?
# flash[:error] = ts("What were you trying to autocomplete?")
# redirect_to(request.env["HTTP_REFERER"] || root_path) and return
# end
# end
#
#########################################
############# LOOKUP ACTIONS GO HERE
# PSEUDS
def pseud
return if params[:term].blank?
render_output(Pseud.autocomplete_lookup(search_param: params[:term], autocomplete_prefix: "autocomplete_pseud").map { |res| Pseud.fullname_from_autocomplete(res) })
end
## TAGS
private
def tag_output(search_param, tag_type)
tags = Tag.autocomplete_lookup(search_param: search_param, autocomplete_prefix: "autocomplete_tag_#{tag_type}")
render_output tags.map {|r| Tag.name_from_autocomplete(r)}
end
public
# these are all basically duplicates but make our calls to autocomplete more readable
def tag; tag_output(params[:term], params[:type] || "all"); end
def fandom; tag_output(params[:term], "fandom"); end
def character; tag_output(params[:term], "character"); end
def relationship; tag_output(params[:term], "relationship"); end
def freeform; tag_output(params[:term], "freeform"); end
## TAGS IN FANDOMS
private
def tag_in_fandom_output(params)
render_output(Tag.autocomplete_fandom_lookup(params).map {|r| Tag.name_from_autocomplete(r)})
end
public
def character_in_fandom; tag_in_fandom_output(params.merge({tag_type: "character"})); end
def relationship_in_fandom; tag_in_fandom_output(params.merge({tag_type: "relationship"})); end
## TAGS IN SETS
#
# Note that only tagsets in OwnedTagSets are in autocomplete
#
# expects the following params:
# :tag_set - tag set ids comma-separated
# :tag_type - tag type as a string unless "all" desired
# :in_any - set to false if only want tags in ALL specified sets
# :term - the search term
def tags_in_sets
results = TagSet.autocomplete_lookup(params)
render_output(results.map {|r| Tag.name_from_autocomplete(r)})
end
# expects the following params:
# :fandom - fandom name(s) as an array or a comma-separated string
# :tag_set - tag set id(s) as an array or a comma-separated string
# :tag_type - tag type as a string unless "all" desired
# :include_wrangled - set to false if you only want tags from the set associations and NOT tags wrangled into the fandom
# :fallback - set to false to NOT do
# :term - the search term
def associated_tags
if params[:fandom].blank?
render_output([ts("Please select a fandom first!")])
else
results = TagSetAssociation.autocomplete_lookup(params)
render_output(results.map {|r| Tag.name_from_autocomplete(r)})
end
end
## NONCANONICAL TAGS
def noncanonical_tag
search_param = Query.new.escape_reserved_characters(params[:term])
raise "Redshirt: Attempted to constantize invalid class initialize noncanonical_tag #{params[:type].classify}" unless Tag::TYPES.include?(params[:type].classify)
tag_class = params[:type].classify.constantize
one_tag = tag_class.find_by(canonical: false, name: params[:term]) if params[:term].present?
# If there is an exact match in the database, ensure it is the first thing suggested.
match = if one_tag
[one_tag.name]
else
[]
end
# As explained in https://stackoverflow.com/a/54080114, the Elasticsearch suggestion suggester does not support
# matches in the middle of a series of words. Therefore, we break the autocomplete query into its individual
# words based on whitespace except for the last word, which could be incomplete, so a prefix match is
# appropriate. This emulates the behavior of SQL `LIKE '%text%'.
word_list = search_param.split
last_word = word_list.pop
search_list = word_list.map { |w| { term: { name: { value: w, case_insensitive: true } } } } + [{ prefix: { name: { value: last_word, case_insensitive: true } } }]
begin
# Size is chosen so we get enough search results from each shard.
search_results = $elasticsearch.search(
index: TagIndexer.index_name,
body: { size: "100", query: { bool: { filter: [{ match: { tag_type: params[:type].capitalize } }, { match: { canonical: false } }], must: search_list } } }
)
render_output((match + search_results["hits"]["hits"].first(10).map { |t| t["_source"]["name"] }).uniq)
rescue Elastic::Transport::Transport::Errors::BadRequest
render_output(match)
end
end
# more-specific autocompletes should be added below here when they can't be avoided
# look up collections ranked by number of items they contain
def collection_fullname
results = Collection.autocomplete_lookup(search_param: params[:term], autocomplete_prefix: "autocomplete_collection_all").map {|res| Collection.fullname_from_autocomplete(res)}
render_output(results)
end
# return collection names
def open_collection_names
# in this case we want different ids from names so we can display the title but only put in the name
results = Collection.autocomplete_lookup(search_param: params[:term], autocomplete_prefix: "autocomplete_collection_open").map do |str|
{id: (whole_name = Collection.name_from_autocomplete(str)), name: Collection.title_from_autocomplete(str) + " (#{whole_name})" }
end
respond_with(results)
end
# For creating collections, autocomplete the name of a parent collection owned by the user only
def collection_parent_name
render_output(current_user.maintained_collections.top_level.with_name_like(params[:term]).pluck(:name).sort)
end
# for looking up existing urls for external works to avoid duplication
def external_work
render_output(ExternalWork.where(["url LIKE ?", '%' + params[:term] + '%']).limit(10).order(:url).pluck(:url))
end
# the pseuds of the potential matches who could fulfill the requests in the given signup
def potential_offers
potential_matches(false)
end
# the pseuds of the potential matches who want the offers in the given signup
def potential_requests
potential_matches(true)
end
# Return matching potential requests or offers
def potential_matches(return_requests=true)
search_param = params[:term]
signup_id = params[:signup_id]
signup = ChallengeSignup.find(signup_id)
pmatches = return_requests ?
signup.offer_potential_matches.sort.reverse.map {|pm| pm.request_signup.pseud.byline} :
signup.request_potential_matches.sort.reverse.map {|pm| pm.offer_signup.pseud.byline}
pmatches.select! { |pm| pm.match(/#{Regexp.escape(search_param)}/) } if search_param.present?
render_output(pmatches)
end
# owned tag sets that are usable by all
def owned_tag_sets
if params[:term].length > 0
search_param = '%' + params[:term] + '%'
render_output(OwnedTagSet.limit(10).order(:title).usable.where("owned_tag_sets.title LIKE ?", search_param).collect(&:title))
end
end
# skins for parenting
def site_skins
if params[:term].present?
search_param = '%' + params[:term] + '%'
query = Skin.site_skins.where("title LIKE ?", search_param).limit(15).sort_by_recent
if logged_in?
query = query.approved_or_owned_by(current_user)
else
query = query.approved_skins
end
render_output(query.pluck(:title))
end
end
# admin posts for translations, formatted as Admin Post Title (Post #id)
def admin_posts
if params[:term].present?
search_param = '%' + params[:term] + '%'
results = AdminPost.non_translated.where("title LIKE ?", search_param).limit(ArchiveConfig.MAX_RECENT).map do |result|
{id: (post_id = result.id),
name: result.title + " (Post ##{post_id})" }
end
respond_with(results)
end
end
def admin_post_tags
if params[:term].present?
search_param = '%' + params[:term].strip + '%'
query = AdminPostTag.where("name LIKE ?", search_param).limit(ArchiveConfig.MAX_RECENT)
render_output(query.pluck(:name))
end
end
private
# Because of the respond_to :json at the top of the controller, this will return a JSON-encoded
# response which the autocomplete javascript on the other end should be able to handle :)
def render_output(result_strings)
if result_strings.first.is_a?(String)
respond_with(result_strings.map {|str| {id: str, name: str}})
else
respond_with(result_strings)
end
end
end

View file

@ -0,0 +1,89 @@
module Blocked
class UsersController < ApplicationController
before_action :set_user
before_action :check_ownership, except: :index
before_action :check_ownership_or_admin, only: :index
before_action :check_admin_permissions, only: :index
before_action :build_block, only: [:confirm_block, :create]
before_action :set_block, only: [:confirm_unblock, :destroy]
# GET /users/:user_id/blocked/users
def index
@blocks = @user.blocks_as_blocker
.joins(:blocked)
.includes(blocked: [:pseuds, { default_pseud: { icon_attachment: { blob: {
variant_records: { image_attachment: :blob },
preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } }
} } } }])
.order(created_at: :desc).order(id: :desc).page(params[:page])
@pseuds = @blocks.map { |b| b.blocked.default_pseud }
@rec_counts = Pseud.rec_counts_for_pseuds(@pseuds)
@work_counts = Pseud.work_counts_for_pseuds(@pseuds)
@page_subtitle = "Blocked Users"
end
# GET /users/:user_id/blocked/users/confirm_block
def confirm_block
@hide_dashboard = true
return if @block.valid?
# We can't block this user for whatever reason
flash[:error] = @block.errors.full_messages.first
redirect_to user_blocked_users_path(@user)
end
# GET /users/:user_id/blocked/users/confirm_unblock
def confirm_unblock
@hide_dashboard = true
@blocked = @block.blocked
end
# POST /users/:user_id/blocked/users
def create
if @block.save
flash[:notice] = t(".blocked", name: @block.blocked.login)
else
# We can't block this user for whatever reason
flash[:error] = @block.errors.full_messages.first
end
redirect_to user_blocked_users_path(@user)
end
# DELETE /users/:user_id/blocked/users/:id
def destroy
@block.destroy
flash[:notice] = t(".unblocked", name: @block.blocked.login)
redirect_to user_blocked_users_path(@user)
end
private
# Sets the user whose blocks we're viewing/modifying.
def set_user
@user = User.find_by!(login: params[:user_id])
@check_ownership_of = @user
end
# Builds (but doesn't save) a block matching the desired params:
def build_block
blocked_byline = params.fetch(:blocked_id, "")
@block = @user.blocks_as_blocker.build(blocked_byline: blocked_byline)
@blocked = @block.blocked
end
def set_block
@block = @user.blocks_as_blocker.find(params[:id])
end
def check_admin_permissions
authorize Block if logged_in_as_admin?
end
end
end

View file

@ -0,0 +1,397 @@
class BookmarksController < ApplicationController
before_action :load_collection
before_action :load_owner, only: [:index]
before_action :load_bookmarkable, only: [:index, :new, :create]
before_action :users_only, only: [:new, :create, :edit, :update]
before_action :check_user_status, only: [:new, :create, :edit, :update]
before_action :check_parent_visible, only: [:index, :new]
before_action :load_bookmark, only: [:show, :edit, :update, :destroy, :confirm_delete, :share]
before_action :check_visibility, only: [:show, :share]
before_action :check_ownership, only: [:edit, :update, :destroy, :confirm_delete, :share]
before_action :check_pseud_ownership, only: [:create, :update]
skip_before_action :store_location, only: [:share]
def check_pseud_ownership
if params[:bookmark][:pseud_id]
pseud = Pseud.find(bookmark_params[:pseud_id])
unless pseud && current_user && current_user.pseuds.include?(pseud)
flash[:error] = ts("You can't bookmark with that pseud.")
redirect_to root_path and return
end
end
end
def check_parent_visible
check_visibility_for(@bookmarkable)
end
# get the parent
def load_bookmarkable
if params[:work_id]
@bookmarkable = Work.find(params[:work_id])
elsif params[:external_work_id]
@bookmarkable = ExternalWork.find(params[:external_work_id])
elsif params[:series_id]
@bookmarkable = Series.find(params[:series_id])
end
end
def load_bookmark
@bookmark = Bookmark.find(params[:id])
@check_ownership_of = @bookmark
@check_visibility_of = @bookmark
end
def search
@languages = Language.default_order
options = params[:bookmark_search].present? ? bookmark_search_params : {}
options.merge!(page: params[:page]) if params[:page].present?
options[:show_private] = false
options[:show_restricted] = logged_in? || logged_in_as_admin?
@search = BookmarkSearchForm.new(options)
@page_subtitle = ts("Search Bookmarks")
if params[:bookmark_search].present? && params[:edit_search].blank?
if @search.query.present?
@page_subtitle = ts("Bookmarks Matching '%{query}'", query: @search.query)
end
@bookmarks = @search.search_results.scope(:for_blurb)
flash_search_warnings(@bookmarks)
set_own_bookmarks
render 'search_results'
end
end
def index
if @bookmarkable
@bookmarks = @bookmarkable.bookmarks.not_private.order_by_created_at.paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
@bookmarks = @bookmarks.where(hidden_by_admin: false) unless logged_in_as_admin?
else
base_options = {
show_private: (@user.present? && @user == current_user),
show_restricted: logged_in? || logged_in_as_admin?,
page: params[:page]
}
options = params[:bookmark_search].present? ? bookmark_search_params : {}
if params[:include_bookmark_search].present?
params[:include_bookmark_search].keys.each do |key|
options[key] ||= []
options[key] << params[:include_bookmark_search][key]
options[key].flatten!
end
end
if params[:exclude_bookmark_search].present?
params[:exclude_bookmark_search].keys.each do |key|
# Keep bookmarker tags separate, so we can search for them on bookmarks
# and search for the rest on bookmarkables
options_key = key == "tag_ids" ? :excluded_bookmark_tag_ids : :excluded_tag_ids
options[options_key] ||= []
options[options_key] << params[:exclude_bookmark_search][key]
options[options_key].flatten!
end
end
options.merge!(base_options)
@page_subtitle = index_page_title
if @owner.present?
@search = BookmarkSearchForm.new(options.merge(faceted: true, parent: @owner))
if @user.blank?
# When it's not a particular user's bookmarks, we want
# to list *bookmarkable* items to avoid duplication
@bookmarkable_items = @search.bookmarkable_search_results.scope(:for_blurb)
flash_search_warnings(@bookmarkable_items)
@facets = @bookmarkable_items.facets
else
# We're looking at a particular user's bookmarks, so
# just retrieve the standard search results and their facets.
@bookmarks = @search.search_results.scope(:for_blurb)
flash_search_warnings(@bookmarks)
@facets = @bookmarks.facets
end
if @search.options[:excluded_tag_ids].present? || @search.options[:excluded_bookmark_tag_ids].present?
# Excluded tags do not appear in search results, so we need to generate empty facets
# to keep them as checkboxes on the filters.
excluded_tag_ids = @search.options[:excluded_tag_ids] || []
excluded_bookmark_tag_ids = @search.options[:excluded_bookmark_tag_ids] || []
# It's possible to determine the tag types by looking at
# the original parameters params[:exclude_bookmark_search],
# but we need the tag names too, so a database query is unavoidable.
tags = Tag.where(id: excluded_tag_ids + excluded_bookmark_tag_ids)
tags.each do |tag|
if excluded_tag_ids.include?(tag.id.to_s)
key = tag.class.to_s.underscore
@facets[key] ||= []
@facets[key] << QueryFacet.new(tag.id, tag.name, 0)
end
if excluded_bookmark_tag_ids.include?(tag.id.to_s)
key = 'tag'
@facets[key] ||= []
@facets[key] << QueryFacet.new(tag.id, tag.name, 0)
end
end
end
elsif use_caching?
@bookmarks = Rails.cache.fetch("bookmarks/index/latest/v2_true", expires_in: ArchiveConfig.SECONDS_UNTIL_BOOKMARK_INDEX_EXPIRE.seconds) do
search = BookmarkSearchForm.new(show_private: false, show_restricted: false, sort_column: 'created_at')
results = search.search_results.scope(:for_blurb)
flash_search_warnings(results)
results.to_a
end
else
@bookmarks = Bookmark.latest.for_blurb.to_a
end
end
set_own_bookmarks
@pagy =
if @bookmarks.respond_to?(:total_pages)
pagy_query_result(@bookmarks)
elsif @bookmarkable_items.respond_to?(:total_pages)
pagy_query_result(@bookmarkable_items)
end
end
# GET /:locale/bookmark/:id
# GET /:locale/users/:user_id/bookmarks/:id
# GET /:locale/works/:work_id/bookmark/:id
# GET /:locale/external_works/:external_work_id/bookmark/:id
def show
end
# GET /bookmarks/new
# GET /bookmarks/new.xml
def new
@bookmark = Bookmark.new
respond_to do |format|
format.html
format.js {
@button_name = ts("Create")
@action = :create
render action: "bookmark_form_dynamic"
}
end
end
# GET /bookmarks/1/edit
def edit
@bookmarkable = @bookmark.bookmarkable
respond_to do |format|
format.html
format.js {
@button_name = ts("Update")
@action = :update
render action: "bookmark_form_dynamic"
}
end
end
# POST /bookmarks
# POST /bookmarks.xml
def create
@bookmarkable ||= ExternalWork.new(external_work_params)
@bookmark = Bookmark.new(bookmark_params.merge(bookmarkable: @bookmarkable))
if @bookmark.errors.empty? && @bookmark.save
flash[:notice] = ts("Bookmark was successfully created. It should appear in bookmark listings within the next few minutes.")
redirect_to(bookmark_path(@bookmark))
else
render :new
end
end
# PUT /bookmarks/1
# PUT /bookmarks/1.xml
def update
new_collections = []
unapproved_collections = []
errors = []
bookmark_params[:collection_names]&.split(",")&.map(&:strip)&.uniq&.each do |collection_name|
collection = Collection.find_by(name: collection_name)
if collection.nil?
errors << ts("%{name} does not exist.", name: collection_name)
else
if @bookmark.collections.include?(collection)
next
elsif collection.closed? && !collection.user_is_maintainer?(User.current_user)
errors << ts("%{title} is closed to new submissions.", title: collection.title)
elsif @bookmark.add_to_collection(collection) && @bookmark.save
if @bookmark.approved_collections.include?(collection)
new_collections << collection
else
unapproved_collections << collection
end
else
errors << ts("Something went wrong trying to add collection %{title}, sorry!", title: collection.title)
end
end
end
# messages to the user
unless errors.empty?
flash[:error] = ts("We couldn't add your submission to the following collections: ") + errors.join("<br />")
end
unless new_collections.empty?
flash[:notice] = ts("Added to collection(s): %{collections}.",
collections: new_collections.collect(&:title).join(", "))
end
unless unapproved_collections.empty?
flash[:notice] = flash[:notice] ? flash[:notice] + " " : ""
flash[:notice] += if unapproved_collections.size > 1
ts("You have submitted your bookmark to moderated collections (%{all_collections}). It will not become a part of those collections until it has been approved by a moderator.", all_collections: unapproved_collections.map(&:title).join(", "))
else
ts("You have submitted your bookmark to the moderated collection '%{collection}'. It will not become a part of the collection until it has been approved by a moderator.", collection: unapproved_collections.first.title)
end
end
flash[:notice] = (flash[:notice]).html_safe unless flash[:notice].blank?
flash[:error] = (flash[:error]).html_safe unless flash[:error].blank?
if @bookmark.update(bookmark_params) && errors.empty?
flash[:notice] = flash[:notice] ? " " + flash[:notice] : ""
flash[:notice] = ts("Bookmark was successfully updated.").html_safe + flash[:notice]
flash[:notice] = flash[:notice].html_safe
redirect_to(@bookmark)
else
@bookmarkable = @bookmark.bookmarkable
render :edit and return
end
end
# GET /bookmarks/1/share
def share
if request.xhr?
if @bookmark.bookmarkable.is_a?(Work) && @bookmark.bookmarkable.unrevealed?
render template: "errors/404", status: :not_found
else
render layout: false
end
else
# Avoid getting an unstyled page if JavaScript is disabled
flash[:error] = ts("Sorry, you need to have JavaScript enabled for this.")
redirect_back(fallback_location: root_path)
end
end
def confirm_delete
end
# DELETE /bookmarks/1
# DELETE /bookmarks/1.xml
def destroy
@bookmark.destroy
flash[:notice] = ts("Bookmark was successfully deleted.")
redirect_to user_bookmarks_path(current_user)
end
protected
def load_owner
if params[:user_id].present?
@user = User.find_by(login: params[:user_id])
unless @user
raise ActiveRecord::RecordNotFound, "Couldn't find user named '#{params[:user_id]}'"
end
if params[:pseud_id].present?
@pseud = @user.pseuds.find_by(name: params[:pseud_id])
unless @pseud
raise ActiveRecord::RecordNotFound, "Couldn't find pseud named '#{params[:pseud_id]}'"
end
end
end
if params[:tag_id]
@tag = Tag.find_by_name(params[:tag_id])
unless @tag
raise ActiveRecord::RecordNotFound, "Couldn't find tag named '#{params[:tag_id]}'"
end
unless @tag.canonical?
if @tag.merger.present?
redirect_to tag_bookmarks_path(@tag.merger) and return
else
redirect_to tag_path(@tag) and return
end
end
end
@owner = @bookmarkable || @pseud || @user || @collection || @tag
end
def index_page_title
if @owner.present?
owner_name = case @owner.class.to_s
when 'Pseud'
@owner.name
when 'User'
@owner.login
when 'Collection'
@owner.title
else
@owner.try(:name)
end
"#{owner_name} - Bookmarks".html_safe
else
"Latest Bookmarks"
end
end
def set_own_bookmarks
return unless @bookmarks
@own_bookmarks = []
if current_user.is_a?(User)
pseud_ids = current_user.pseuds.pluck(:id)
@own_bookmarks = @bookmarks.select do |b|
pseud_ids.include?(b.pseud_id)
end
end
end
private
def bookmark_params
params.require(:bookmark).permit(
:pseud_id, :bookmarker_notes, :tag_string, :collection_names, :private, :rec
)
end
def external_work_params
params.require(:external_work).permit(
:url, :author, :title, :fandom_string, :rating_string, :relationship_string,
:character_string, :summary, category_strings: []
)
end
def bookmark_search_params
params.require(:bookmark_search).permit(
:bookmark_query,
:bookmarkable_query,
:bookmarker,
:bookmark_notes,
:rec,
:with_notes,
:bookmarkable_type,
:language_id,
:date,
:bookmarkable_date,
:sort_column,
:other_tag_names,
:excluded_tag_names,
:other_bookmark_tag_names,
:excluded_bookmark_tag_names,
rating_ids: [],
warning_ids: [], # backwards compatibility
archive_warning_ids: [],
category_ids: [],
fandom_ids: [],
character_ids: [],
relationship_ids: [],
freeform_ids: [],
tag_ids: [],
)
end
end

View file

@ -0,0 +1,109 @@
class Challenge::GiftExchangeController < ChallengesController
before_action :users_only
before_action :load_collection
before_action :load_challenge, except: [:new, :create]
before_action :collection_owners_only, only: [:new, :create, :edit, :update, :destroy]
# ACTIONS
def show
end
def new
if (@collection.challenge)
flash[:notice] = ts("There is already a challenge set up for this collection.")
# TODO this will break if the challenge isn't a gift exchange
redirect_to edit_collection_gift_exchange_path(@collection)
else
@challenge = GiftExchange.new
end
end
def edit
end
def create
@challenge = GiftExchange.new(gift_exchange_params)
if @challenge.save
@collection.challenge = @challenge
@collection.save
flash[:notice] = ts('Challenge was successfully created.')
redirect_to collection_profile_path(@collection)
else
render action: :new
end
end
def update
if @challenge.update(gift_exchange_params)
flash[:notice] = ts('Challenge was successfully updated.')
# expire the cache on the signup form
ActionController::Base.new.expire_fragment('challenge_signups/new')
# see if we initialized the tag set
redirect_to collection_profile_path(@collection)
else
render action: :edit
end
end
def destroy
@challenge.destroy
flash[:notice] = 'Challenge settings were deleted.'
redirect_to @collection
end
private
def gift_exchange_params
params.require(:gift_exchange).permit(
:signup_open, :time_zone, :signups_open_at_string, :signups_close_at_string,
:assignments_due_at_string, :requests_summary_visible, :requests_num_required,
:requests_num_allowed, :offers_num_required, :offers_num_allowed,
:signup_instructions_general, :signup_instructions_requests,
:signup_instructions_offers, :request_url_label, :offer_url_label,
:offer_description_label, :request_description_label, :works_reveal_at_string,
:authors_reveal_at_string,
request_restriction_attributes: [
:id, :optional_tags_allowed, :title_required, :title_allowed, :description_required,
:description_allowed, :url_required, :url_allowed, :fandom_num_required,
:fandom_num_allowed, :allow_any_fandom, :require_unique_fandom,
:character_num_required, :character_num_allowed, :allow_any_character,
:require_unique_character, :relationship_num_required, :relationship_num_allowed,
:allow_any_relationship, :require_unique_relationship, :rating_num_required,
:rating_num_allowed, :allow_any_rating, :require_unique_rating,
:category_num_required, :category_num_allowed, :allow_any_category,
:require_unique_category, :freeform_num_required, :freeform_num_allowed,
:allow_any_freeform, :require_unique_freeform, :archive_warning_num_required,
:archive_warning_num_allowed, :allow_any_archive_warning, :require_unique_archive_warning
],
offer_restriction_attributes: [
:id, :optional_tags_allowed, :title_required, :title_allowed,
:description_required, :description_allowed, :url_required, :url_allowed,
:fandom_num_required, :fandom_num_allowed, :allow_any_fandom,
:require_unique_fandom, :character_num_required, :character_num_allowed,
:allow_any_character, :require_unique_character, :relationship_num_required,
:relationship_num_allowed, :allow_any_relationship, :require_unique_relationship,
:rating_num_required, :rating_num_allowed, :rating_num_required, :allow_any_rating, :require_unique_rating,
:category_num_required, :category_num_allowed, :allow_any_category,
:require_unique_category, :freeform_num_required, :freeform_num_allowed,
:allow_any_freeform, :require_unique_freeform, :archive_warning_num_required,
:archive_warning_num_allowed, :allow_any_archive_warning, :require_unique_archive_warning,
:tag_sets_to_add, :character_restrict_to_fandom,
:character_restrict_to_tag_set, :relationship_restrict_to_fandom,
:relationship_restrict_to_tag_set,
tag_sets_to_remove: []
],
potential_match_settings_attributes: [
:id, :num_required_prompts, :num_required_fandoms, :num_required_characters,
:num_required_relationships, :num_required_freeforms, :num_required_categories,
:num_required_ratings, :num_required_archive_warnings, :include_optional_fandoms,
:include_optional_characters, :include_optional_relationships,
:include_optional_freeforms, :include_optional_categories, :include_optional_ratings,
:include_optional_archive_warnings
]
)
end
end

View file

@ -0,0 +1,80 @@
class Challenge::PromptMemeController < ChallengesController
before_action :users_only
before_action :load_collection
before_action :load_challenge, except: [:new, :create]
before_action :collection_owners_only, only: [:new, :create, :edit, :update, :destroy]
# ACTIONS
# is actually a blank page - should it be redirected to collection profile?
def show
end
# The new form for prompt memes is actually the challenge settings page because challenges are always created in the context of a collection.
def new
if (@collection.challenge)
flash[:notice] = ts("There is already a challenge set up for this collection.")
redirect_to edit_collection_prompt_meme_path(@collection)
else
@challenge = PromptMeme.new
end
end
def edit
end
def create
@challenge = PromptMeme.new(prompt_meme_params)
if @challenge.save
@collection.challenge = @challenge
@collection.save
flash[:notice] = ts('Challenge was successfully created.')
redirect_to collection_profile_path(@collection)
else
render action: :new
end
end
def update
if @challenge.update(prompt_meme_params)
flash[:notice] = 'Challenge was successfully updated.'
# expire the cache on the signup form
ActionController::Base.new.expire_fragment('challenge_signups/new')
redirect_to @collection
else
render action: :edit
end
end
def destroy
@challenge.destroy
flash[:notice] = 'Challenge settings were deleted.'
redirect_to @collection
end
private
def prompt_meme_params
params.require(:prompt_meme).permit(
:signup_open, :time_zone, :signups_open_at_string, :signups_close_at_string,
:assignments_due_at_string, :anonymous, :requests_num_required, :requests_num_allowed,
:signup_instructions_general, :signup_instructions_requests, :request_url_label,
:request_description_label, :works_reveal_at_string, :authors_reveal_at_string,
request_restriction_attributes: [ :id, :optional_tags_allowed, :title_required,
:title_allowed, :description_required, :description_allowed, :url_required,
:url_allowed, :fandom_num_required, :fandom_num_allowed, :require_unique_fandom,
:character_num_required, :character_num_allowed, :category_num_required,
:category_num_allowed, :require_unique_category, :require_unique_character,
:relationship_num_required, :relationship_num_allowed, :require_unique_relationship,
:rating_num_required, :rating_num_allowed, :require_unique_rating,
:freeform_num_required, :freeform_num_allowed, :require_unique_freeform,
:archive_warning_num_required, :archive_warning_num_allowed, :require_unique_archive_warning,
:tag_sets_to_remove, :tag_sets_to_add, :character_restrict_to_fandom,
:character_restrict_to_tag_set, :relationship_restrict_to_fandom,
:relationship_restrict_to_tag_set, tag_sets_to_remove: []
]
)
end
end

View file

@ -0,0 +1,243 @@
class ChallengeAssignmentsController < ApplicationController
before_action :users_only
before_action :load_collection, except: [:index]
before_action :load_challenge, except: [:index]
before_action :collection_owners_only, except: [:index, :show, :default]
before_action :load_assignment_from_id, only: [:show, :default]
before_action :owner_only, only: [:default]
before_action :check_signup_closed, except: [:index]
before_action :check_assignments_not_sent, only: [:generate, :set, :send_out]
before_action :check_assignments_sent, only: [:default, :purge, :confirm_purge]
# PERMISSIONS AND STATUS CHECKING
def load_challenge
@challenge = @collection.challenge if @collection
no_challenge unless @challenge
end
def no_challenge
flash[:error] = t('challenge_assignments.no_challenge', default: "What challenge did you want to work with?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def load_assignment_from_id
@challenge_assignment = @collection.assignments.find(params[:id])
end
def owner_only
return if current_user == @challenge_assignment.offering_pseud.user
flash[:error] = t("challenge_assignments.validation.not_owner")
redirect_to root_path
end
def check_signup_closed
signup_open and return unless !@challenge.signup_open
end
def signup_open
flash[:error] = t('challenge_assignments.signup_open', default: "Signup is currently open, you cannot make assignments now.")
redirect_to @collection rescue redirect_to '/'
false
end
def check_assignments_not_sent
assignments_sent and return unless @challenge.assignments_sent_at.nil?
end
def assignments_sent
flash[:error] = t('challenge_assignments.assignments_sent', default: "Assignments have already been sent! If necessary, you can purge them.")
redirect_to collection_assignments_path(@collection) rescue redirect_to '/'
false
end
def check_assignments_sent
assignments_not_sent and return unless @challenge.assignments_sent_at
end
def assignments_not_sent
flash[:error] = t('challenge_assignments.assignments_not_sent', default: "Assignments have not been sent! You might want matching instead.")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
# ACTIONS
def index
if params[:user_id] && (@user = User.find_by(login: params[:user_id]))
if current_user == @user
if params[:collection_id] && (@collection = Collection.find_by(name: params[:collection_id]))
@challenge_assignments = @user.offer_assignments.in_collection(@collection).undefaulted + @user.pinch_hit_assignments.in_collection(@collection).undefaulted
else
@challenge_assignments = @user.offer_assignments.undefaulted + @user.pinch_hit_assignments.undefaulted
end
else
flash[:error] = ts("You aren't allowed to see that user's assignments.")
redirect_to '/' and return
end
else
# do error-checking for the collection case
return unless load_collection
@challenge = @collection.challenge if @collection
signup_open and return unless !@challenge.signup_open
access_denied and return unless @challenge.user_allowed_to_see_assignments?(current_user)
# we temporarily are ordering by requesting pseud to avoid left join
@assignments = case
when params[:pinch_hit]
# order by pinch hitter name
ChallengeAssignment.unfulfilled_in_collection(@collection).undefaulted.with_pinch_hitter.joins("INNER JOIN pseuds ON (challenge_assignments.pinch_hitter_id = pseuds.id)").order("pseuds.name")
when params[:fulfilled]
@collection.assignments.fulfilled.order_by_requesting_pseud
when params[:unfulfilled]
ChallengeAssignment.unfulfilled_in_collection(@collection).undefaulted.order_by_requesting_pseud
else
@collection.assignments.defaulted.uncovered.order_by_requesting_pseud
end
@assignments = @assignments.page(params[:page])
end
end
def show
unless @challenge.user_allowed_to_see_assignments?(current_user) || @challenge_assignment.offering_pseud.user == current_user
flash[:error] = ts("You aren't allowed to see that assignment!")
redirect_to "/" and return
end
if @challenge_assignment.defaulted?
flash[:notice] = ts("This assignment has been defaulted-on.")
end
end
def generate
# regenerate assignments using the current potential matches
ChallengeAssignment.generate(@collection)
flash[:notice] = ts("Beginning regeneration of assignments. This may take some time, especially if your challenge is large.")
redirect_to collection_potential_matches_path(@collection)
end
def send_out
no_giver_count = @collection.assignments.with_request.with_no_offer.count
no_recip_count = @collection.assignments.with_offer.with_no_request.count
if (no_giver_count + no_recip_count) > 0
flash[:error] = ts("Some participants still aren't assigned. Please either delete them or match them to a placeholder before sending out assignments.")
redirect_to collection_potential_matches_path(@collection)
else
ChallengeAssignment.send_out(@collection)
flash[:notice] = "Assignments are now being sent out."
redirect_to collection_assignments_path(@collection)
end
end
def set
# update all the assignments
all_assignment_params = challenge_assignment_params
@assignments = []
@collection.assignments.where(id: all_assignment_params.keys).each do |assignment|
assignment_params = all_assignment_params[assignment.id.to_s]
@assignments << assignment unless assignment.update(assignment_params)
end
ChallengeAssignment.update_placeholder_assignments!(@collection)
if @assignments.empty?
flash[:notice] = "Assignments updated"
redirect_to collection_potential_matches_path(@collection)
else
flash[:error] = ts("These assignments could not be saved because the two participants do not match. Did you mean to write in a giver?")
render template: "potential_matches/index"
end
end
def confirm_purge
end
def purge
ChallengeAssignment.clear!(@collection)
@challenge.assignments_sent_at = nil
@challenge.save
flash[:notice] = ts("Assignments purged!")
redirect_to collection_path(@collection)
end
def update_multiple
@errors = []
params.each_pair do |key, val|
action, id = key.split(/_/)
next unless %w(approve default undefault cover).include?(action)
assignment = @collection.assignments.find_by(id: id)
unless assignment
@errors << ts("Couldn't find assignment with id #{id}!")
next
end
case action
when "default"
# default_assignment_id = y/n
assignment.default || (@errors << ts("We couldn't default the assignment for %{offer}", offer: assignment.offer_byline))
when "undefault"
# undefault_[assignment_id] = y/n - if set, undefault
assignment.defaulted_at = nil
assignment.save || (@errors << ts("We couldn't undefault the assignment covering %{request}.", request: assignment.request_byline))
when "approve"
assignment.get_collection_item.approve_by_collection if assignment.get_collection_item
when "cover"
# cover_[assignment_id] = pinch hitter pseud
next if val.blank? || assignment.pinch_hitter.try(:byline) == val
pseud = Pseud.parse_byline(val)
if pseud.nil?
@errors << ts("We couldn't find the user %{val} to assign that to.", val: val)
else
assignment.cover(pseud) || (@errors << ts("We couldn't assign %{val} to cover %{request}.", val: val, request: assignment.request_byline))
end
end
end
if @errors.empty?
flash[:notice] = "Assignment updates complete!"
redirect_to collection_assignments_path(@collection)
else
flash[:error] = @errors
redirect_to collection_assignments_path(@collection)
end
end
def default_all
# mark all unfulfilled assignments as defaulted
unfulfilled_assignments = ChallengeAssignment.unfulfilled_in_collection(@collection).readonly(false)
unfulfilled_assignments.update_all defaulted_at: Time.now
flash[:notice] = "All unfulfilled assignments marked as defaulting."
redirect_to collection_assignments_path(@collection)
end
def default
@challenge_assignment.defaulted_at = Time.now
@challenge_assignment.save
assignments_page_url = collection_assignments_url(@challenge_assignment.collection)
@challenge_assignment.collection.notify_maintainers_challenge_default(@challenge_assignment, assignments_page_url)
flash[:notice] = "We have notified the collection maintainers that you had to default on your assignment."
redirect_to user_assignments_path(current_user)
end
private
def challenge_assignment_params
params.slice(:challenge_assignments).permit(
challenge_assignments: [
:request_signup_pseud,
:offer_signup_pseud,
:pinch_hitter_byline
]
).require(:challenge_assignments)
end
end

View file

@ -0,0 +1,148 @@
class ChallengeClaimsController < ApplicationController
before_action :users_only
before_action :load_collection, except: [:index]
before_action :collection_owners_only, except: [:index, :show, :create, :destroy]
before_action :load_claim_from_id, only: [:show, :destroy]
before_action :load_challenge, except: [:index]
before_action :allowed_to_destroy, only: [:destroy]
# PERMISSIONS AND STATUS CHECKING
def load_challenge
if @collection
@challenge = @collection.challenge
elsif @challenge_claim
@challenge = @challenge_claim.collection.challenge
end
no_challenge and return unless @challenge
end
def no_challenge
flash[:error] = ts("What challenge did you want to work with?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def load_claim_from_id
@challenge_claim = ChallengeClaim.find(params[:id])
no_claim and return unless @challenge_claim
end
def no_claim
flash[:error] = ts("What claim did you want to work on?")
if @collection
redirect_to collection_path(@collection) rescue redirect_to '/'
else
redirect_to user_path(@user) rescue redirect_to '/'
end
false
end
def load_user
@user = User.find_by(login: params[:user_id]) if params[:user_id]
no_user and return unless @user
end
def no_user
flash[:error] = ts("What user were you trying to work with?")
redirect_to "/" and return
false
end
def owner_only
unless @user == @challenge_claim.claiming_user
flash[:error] = ts("You aren't the claimer of that prompt.")
redirect_to "/" and return false
end
end
def allowed_to_destroy
@challenge_claim.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
end
# ACTIONS
def index
if !(@collection = Collection.find_by(name: params[:collection_id])).nil? && @collection.closed? && !@collection.user_is_maintainer?(current_user)
flash[:notice] = ts("This challenge is currently closed to new posts.")
end
if params[:collection_id]
return unless load_collection
@challenge = @collection.challenge
not_allowed(@collection) unless user_scoped? || @challenge.user_allowed_to_see_assignments?(current_user)
@claims = ChallengeClaim.unposted_in_collection(@collection)
@claims = @claims.where(claiming_user_id: current_user.id) if user_scoped?
# sorting
set_sort_order
if params[:sort] == "claimer"
@claims = @claims.order_by_offering_pseud(@sort_direction)
else
@claims = @claims.order(@sort_order)
end
elsif params[:user_id] && (@user = User.find_by(login: params[:user_id]))
if current_user == @user
@claims = @user.request_claims.order_by_date.unposted
if params[:posted]
@claims = @user.request_claims.order_by_date.posted
end
if params[:collection_id] && (@collection = Collection.find_by(name: params[:collection_id]))
@claims = @claims.in_collection(@collection)
end
else
flash[:error] = ts("You aren't allowed to see that user's claims.")
redirect_to '/' and return
end
end
@claims = @claims.paginate page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE
end
def show
# this is here just as a failsafe, this path should not be used
redirect_to collection_prompt_path(@collection, @challenge_claim.request_prompt)
end
def create
# create a new claim
prompt = @collection.prompts.find(params[:prompt_id])
claim = prompt.request_claims.build(claiming_user: current_user)
if claim.save
flash[:notice] = "New claim made."
else
flash[:error] = "We couldn't save the new claim."
end
redirect_to collection_claims_path(@collection, for_user: true)
end
def destroy
redirect_path = collection_claims_path(@collection)
flash[:notice] = ts("The claim was deleted.")
if @challenge_claim.claiming_user == current_user
redirect_path = collection_claims_path(@collection, for_user: true)
flash[:notice] = ts("Your claim was deleted.")
end
begin
@challenge_claim.destroy
rescue
flash.delete(:notice)
flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
end
redirect_to redirect_path
end
private
def user_scoped?
params[:for_user].to_s.casecmp?("true")
end
end

View file

@ -0,0 +1,41 @@
class ChallengeRequestsController < ApplicationController
before_action :load_collection
before_action :check_visibility
def check_visibility
unless @collection
flash.now[:notice] = ts("Collection could not be found")
redirect_to '/' and return
end
unless @collection.challenge_type == "PromptMeme" || (@collection.challenge_type == "GiftExchange" && @collection.challenge.user_allowed_to_see_requests_summary?(current_user))
flash.now[:notice] = ts("You are not allowed to view the requests summary!")
redirect_to collection_path(@collection) and return
end
end
def index
@show_request_fandom_tags = @collection.challenge.request_restriction.allowed("fandom").positive?
# sorting
set_sort_order
direction = (@sort_direction.casecmp("ASC").zero? ? "ASC" : "DESC")
# actual content, do the efficient method unless we need the full query
if @sort_column == "fandom"
query = "SELECT prompts.*, GROUP_CONCAT(tags.name) AS tagnames FROM prompts INNER JOIN set_taggings ON prompts.tag_set_id = set_taggings.tag_set_id
INNER JOIN tags ON tags.id = set_taggings.tag_id
WHERE prompts.type = 'Request' AND tags.type = 'Fandom' AND prompts.collection_id = " + @collection.id.to_s + " GROUP BY prompts.id ORDER BY tagnames " + @sort_direction
@requests = Prompt.paginate_by_sql(query, page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
elsif @sort_column == "prompter" && !@collection.prompts.where(anonymous: true).exists?
@requests = @collection.prompts.where("type = 'Request'").
joins(challenge_signup: :pseud).
order("pseuds.name #{direction}").
paginate(page: params[:page])
else
@requests = @collection.prompts.where("type = 'Request'").order(@sort_order).paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
end
end
end

View file

@ -0,0 +1,396 @@
# For exporting to Excel CSV format
require 'csv'
class ChallengeSignupsController < ApplicationController
include ExportsHelper
before_action :users_only, except: [:summary]
before_action :load_collection, except: [:index]
before_action :load_challenge, except: [:index]
before_action :load_signup_from_id, only: [:show, :edit, :update, :destroy, :confirm_delete]
before_action :allowed_to_destroy, only: [:destroy, :confirm_delete]
before_action :signup_owner_only, only: [:edit, :update]
before_action :maintainer_or_signup_owner_only, only: [:show]
before_action :check_signup_open, only: [:new, :create, :edit, :update]
before_action :check_pseud_ownership, only: [:create, :update]
before_action :check_signup_in_collection, only: [:show, :edit, :update, :destroy, :confirm_delete]
def load_challenge
@challenge = @collection.challenge
no_challenge and return unless @challenge
end
def no_challenge
flash[:error] = ts("What challenge did you want to sign up for?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def check_signup_open
signup_closed and return unless (@challenge.signup_open || @collection.user_is_maintainer?(current_user))
end
def signup_closed
flash[:error] = ts("Sign-up is currently closed: please contact a moderator for help.")
redirect_to @collection rescue redirect_to '/'
false
end
def signup_closed_owner?
@collection.challenge_type == "GiftExchange" && !@challenge.signup_open && @collection.user_is_owner?(current_user)
end
def signup_owner_only
not_signup_owner and return unless @challenge_signup.pseud.user == current_user || signup_closed_owner?
end
def maintainer_or_signup_owner_only
not_allowed(@collection) and return unless (@challenge_signup.pseud.user == current_user || @collection.user_is_maintainer?(current_user))
end
def not_signup_owner
flash[:error] = ts("You can't edit someone else's sign-up!")
redirect_to @collection
false
end
def allowed_to_destroy
@challenge_signup.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
end
def load_signup_from_id
@challenge_signup = ChallengeSignup.find(params[:id])
no_signup and return unless @challenge_signup
end
def no_signup
flash[:error] = ts("What sign-up did you want to work on?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def check_pseud_ownership
if params[:challenge_signup][:pseud_id] && (pseud = Pseud.find(params[:challenge_signup][:pseud_id]))
# either you have to own the pseud, OR you have to be a mod editing after signups are closed and NOT changing the pseud
unless current_user.pseuds.include?(pseud) || (@challenge_signup && @challenge_signup.pseud == pseud && signup_closed_owner?)
flash[:error] = ts("You can't sign up with that pseud.")
redirect_to root_path and return
end
end
end
def check_signup_in_collection
unless @challenge_signup.collection_id == @collection.id
flash[:error] = ts("Sorry, that sign-up isn't associated with that collection.")
redirect_to @collection
end
end
#### ACTIONS
def index
if params[:user_id] && (@user = User.find_by(login: params[:user_id]))
if current_user == @user
@challenge_signups = @user.challenge_signups.order_by_date
render action: :index and return
else
flash[:error] = ts("You aren't allowed to see that user's sign-ups.")
redirect_to '/' and return
end
else
load_collection
load_challenge if @collection
return false unless @challenge
end
# using respond_to in order to provide Excel output
# see ExportsHelper for export_csv method
respond_to do |format|
format.html {
if @challenge.user_allowed_to_see_signups?(current_user)
@challenge_signups = @collection.signups.joins(:pseud)
if params[:query]
@query = params[:query]
@challenge_signups = @challenge_signups.where("pseuds.name LIKE ?", '%' + params[:query] + '%')
end
@challenge_signups = @challenge_signups.order("pseuds.name").paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
elsif params[:user_id] && (@user = User.find_by(login: params[:user_id]))
@challenge_signups = @collection.signups.by_user(current_user)
else
not_allowed(@collection)
end
}
format.csv {
if (@collection.gift_exchange? && @challenge.user_allowed_to_see_signups?(current_user)) ||
(@collection.prompt_meme? && @collection.user_is_maintainer?(current_user))
csv_data = self.send("#{@challenge.class.name.underscore}_to_csv")
filename = "#{@collection.name}_signups_#{Time.now.strftime('%Y-%m-%d-%H%M')}.csv"
send_csv_data(csv_data, filename)
else
flash[:error] = ts("You aren't allowed to see the CSV summary.")
redirect_to collection_path(@collection) rescue redirect_to '/' and return
end
}
end
end
def summary
@summary = ChallengeSignupSummary.new(@collection)
if @collection.signups.count < (ArchiveConfig.ANONYMOUS_THRESHOLD_COUNT/2)
flash.now[:notice] = ts("Summary does not appear until at least %{count} sign-ups have been made!", count: ((ArchiveConfig.ANONYMOUS_THRESHOLD_COUNT/2)))
elsif @collection.signups.count > ArchiveConfig.MAX_SIGNUPS_FOR_LIVE_SUMMARY
# too many signups in this collection to show the summary page "live"
modification_time = @summary.cached_time
# The time is always written alongside the cache, so if the time is
# missing, then the cache must be missing as well -- and we want to
# generate it. We also want to generate it if signups are open and it was
# last generated more than an hour ago.
if modification_time.nil? ||
(@collection.challenge.signup_open? && modification_time < 1.hour.ago)
# Touch the cache so that we don't try to generate the summary a second
# time on subsequent page loads.
@summary.touch_cache
# Generate the cache of the summary in the background.
@summary.enqueue_for_generation
end
else
# generate it on the fly
@tag_type = @summary.tag_type
@summary_tags = @summary.summary
@generated_live = true
end
end
def show
unless @challenge_signup.valid?
flash[:error] = ts("This sign-up is invalid. Please check your sign-ups for a duplicate or edit to fix any other problems.")
end
end
protected
def build_prompts
notice = ""
@challenge.class::PROMPT_TYPES.each do |prompt_type|
num_to_build = params["num_#{prompt_type}"] ? params["num_#{prompt_type}"].to_i : @challenge.required(prompt_type)
if num_to_build < @challenge.required(prompt_type)
notice += ts("You must submit at least %{required} #{prompt_type}. ", required: @challenge.required(prompt_type))
num_to_build = @challenge.required(prompt_type)
elsif num_to_build > @challenge.allowed(prompt_type)
notice += ts("You can only submit up to %{allowed} #{prompt_type}. ", allowed: @challenge.allowed(prompt_type))
num_to_build = @challenge.allowed(prompt_type)
elsif params["num_#{prompt_type}"]
notice += ts("Set up %{num} #{prompt_type.pluralize}. ", num: num_to_build)
end
num_existing = @challenge_signup.send(prompt_type).count
num_existing.upto(num_to_build-1) do
@challenge_signup.send(prompt_type).build
end
end
unless notice.blank?
flash[:notice] = notice
end
end
public
def new
if (@challenge_signup = ChallengeSignup.in_collection(@collection).by_user(current_user).first)
flash[:notice] = ts("You are already signed up for this challenge. You can edit your sign-up below.")
redirect_to edit_collection_signup_path(@collection, @challenge_signup)
else
@challenge_signup = ChallengeSignup.new
build_prompts
end
end
def edit
build_prompts
end
def create
@challenge_signup = ChallengeSignup.new(challenge_signup_params)
@challenge_signup.pseud = current_user.default_pseud unless @challenge_signup.pseud
@challenge_signup.collection = @collection
# we check validity first to prevent saving tag sets if invalid
if @challenge_signup.valid? && @challenge_signup.save
flash[:notice] = ts('Sign-up was successfully created.')
redirect_to collection_signup_path(@collection, @challenge_signup)
else
render action: :new
end
end
def update
if @challenge_signup.update(challenge_signup_params)
flash[:notice] = ts('Sign-up was successfully updated.')
redirect_to collection_signup_path(@collection, @challenge_signup)
else
render action: :edit
end
end
def confirm_delete
end
def destroy
unless @challenge.signup_open || @collection.user_is_maintainer?(current_user)
flash[:error] = ts("You cannot delete your sign-up after sign-ups are closed. Please contact a moderator for help.")
else
@challenge_signup.destroy
flash[:notice] = ts("Challenge sign-up was deleted.")
end
if @collection.user_is_maintainer?(current_user) && !@collection.prompt_meme?
redirect_to collection_signups_path(@collection)
elsif @collection.prompt_meme?
redirect_to collection_requests_path(@collection)
else
redirect_to @collection
end
end
protected
def request_to_array(type, request)
any_types = TagSet::TAG_TYPES.select {|type| request && request.send("any_#{type}")}
any_types.map! { |type| ts("Any %{type}", type: type.capitalize) }
tags = request.nil? ? [] : request.tag_set.tags.map {|tag| tag.name}
rarray = [(tags + any_types).join(", ")]
if @challenge.send("#{type}_restriction").optional_tags_allowed
rarray << (request.nil? ? "" : request.optional_tag_set.tags.map {|tag| tag.name}.join(", "))
end
if @challenge.send("#{type}_restriction").title_allowed
rarray << (request.nil? ? "" : sanitize_field(request, :title))
end
if @challenge.send("#{type}_restriction").description_allowed
description = (request.nil? ? "" : sanitize_field(request, :description))
# Didn't find a way to get Excel 2007 to accept line breaks
# withing a field; not even when the row delimiter is set to
# \r\n and linebreaks within the field are only \n. :-(
#
# Thus stripping linebreaks.
rarray << description.gsub(/[\n\r]/, " ")
end
rarray << (request.nil? ? "" : request.url) if
@challenge.send("#{type}_restriction").url_allowed
return rarray
end
def gift_exchange_to_csv
header = ["Pseud", "Email", "Sign-up URL"]
%w(request offer).each do |type|
@challenge.send("#{type.pluralize}_num_allowed").times do |i|
header << "#{type.capitalize} #{i+1} Tags"
header << "#{type.capitalize} #{i+1} Optional Tags" if
@challenge.send("#{type}_restriction").optional_tags_allowed
header << "#{type.capitalize} #{i+1} Title" if
@challenge.send("#{type}_restriction").title_allowed
header << "#{type.capitalize} #{i+1} Description" if
@challenge.send("#{type}_restriction").description_allowed
header << "#{type.capitalize} #{i+1} URL" if
@challenge.send("#{type}_restriction").url_allowed
end
end
csv_array = []
csv_array << header
@collection.signups.each do |signup|
row = [signup.pseud.name, signup.pseud.user.email,
collection_signup_url(@collection, signup)]
%w(request offer).each do |type|
@challenge.send("#{type.pluralize}_num_allowed").times do |i|
row += request_to_array(type, signup.send(type.pluralize)[i])
end
end
csv_array << row
end
csv_array
end
def prompt_meme_to_csv
header = ["Pseud", "Sign-up URL", "Tags"]
header << "Optional Tags" if @challenge.request_restriction.optional_tags_allowed
header << "Title" if @challenge.request_restriction.title_allowed
header << "Description" if @challenge.request_restriction.description_allowed
header << "URL" if @challenge.request_restriction.url_allowed
csv_array = []
csv_array << header
@collection.prompts.where(type: "Request").each do |request|
row =
if request.anonymous?
["(Anonymous)", ""]
else
[request.challenge_signup.pseud.name,
collection_signup_url(@collection, request.challenge_signup)]
end
csv_array << (row + request_to_array("request", request))
end
csv_array
end
private
def challenge_signup_params
params.require(:challenge_signup).permit(
:pseud_id,
requests_attributes: nested_prompt_params,
offers_attributes: nested_prompt_params
)
end
def nested_prompt_params
[
:id,
:title,
:url,
:any_fandom,
:any_character,
:any_relationship,
:any_freeform,
:any_category,
:any_rating,
:any_archive_warning,
:anonymous,
:description,
:_destroy,
tag_set_attributes: [
:id,
:updated_at,
:character_tagnames,
:relationship_tagnames,
:freeform_tagnames,
:category_tagnames,
:rating_tagnames,
:archive_warning_tagnames,
:fandom_tagnames,
character_tagnames: [],
relationship_tagnames: [],
freeform_tagnames: [],
category_tagnames: [],
rating_tagnames: [],
archive_warning_tagnames: [],
fandom_tagnames: [],
],
optional_tag_set_attributes: [
:tagnames
]
]
end
end

View file

@ -0,0 +1,68 @@
class ChallengesController < ApplicationController
# This is the PARENT controller for all the challenge controller classes
#
# Here's how challenges work:
#
# For each TYPE of challenge, you use the challenge generator:
# script/generate challenge WhatEver -- eg, script/generate challenge
# Flashfic, script/generate challenge BigBang, etc
# This creates:
# - a model called Challenge::WhatEver -- eg, Challenge::Flashfic,
# Challenge::BigBang, etc which actually implements a challenge
# - a controller Challenge::WhatEverController --
# Challenge::FlashficController which goes in
# controllers/challenge/flashfic_controller.rb
# - a views subfolder called views/challenge/whatever --
# eg, views/challenge/flashfic/, views/challenge/bigbang/
#
# Example:
# script/generate challenge Flashfic creates:
# - controllers/challenge/flashfics_controller.rb
# - models/challenge/flashfic.rb
# - views/challenges/flashfics/
# - db/migrate/create_challenge_flashfics to create challenge_flashfics table
#
# Setting up an instance:
# - create the collection SGA_Flashfic
# - mark it as a challenge collection
# - choose "Regular Single Prompt (flashfic-style)" as the challenge type
#
# This feeds us into challenges/new and creates a new empty Challenge object
# with collection -> SGA_Flashfic
# and implementation -> Challenge::Flashfic.new
# We are then redirected to Challenge::FlashficController new ->
# views/challenges/flashfic/new.html.erb
#
# These can have lots of options specific to flashfics, including eg how often
# prompts are posted, if they should automatically
# be closed, whether we take user suggestions for prompts, etc.
#
before_action :load_collection
def no_collection
flash[:error] = t('challenge.no_collection',
default: 'What collection did you want to work with?')
redirect_to(request.env['HTTP_REFERER'] || root_path)
false
end
def no_challenge
flash[:error] = t('challenges.no_challenge',
default: 'What challenge did you want to work on?')
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def load_collection
@collection ||= Collection.find_by(name: params[:collection_id]) if
params[:collection_id]
no_collection && return unless @collection
end
def load_challenge
@challenge = @collection.challenge
no_challenge and return unless @challenge
end
end

View file

@ -0,0 +1,285 @@
class ChaptersController < ApplicationController
# only registered users and NOT admin should be able to create new chapters
before_action :users_only, except: [:index, :show, :destroy, :confirm_delete]
before_action :check_user_status, only: [:new, :create, :update, :update_positions]
before_action :check_user_not_suspended, only: [:edit, :confirm_delete, :destroy]
before_action :load_work
# only authors of a work should be able to edit its chapters
before_action :check_ownership, except: [:index, :show]
before_action :check_visibility, only: [:show]
before_action :load_chapter, only: [:show, :edit, :update, :preview, :post, :confirm_delete, :destroy]
cache_sweeper :feed_sweeper
# GET /work/:work_id/chapters
# GET /work/:work_id/chapters.xml
def index
# this route is never used
redirect_to work_path(params[:work_id])
end
# GET /work/:work_id/chapters/manage
def manage
@chapters = @work.chapters_in_order(include_content: false,
include_drafts: true)
end
# GET /work/:work_id/chapters/:id
# GET /work/:work_id/chapters/:id.xml
def show
@tag_groups = @work.tag_groups
redirect_to url_for(controller: :chapters, action: :show, work_id: @work.id, id: params[:selected_id]) and return if params[:selected_id]
@chapters = @work.chapters_in_order(
include_content: false,
include_drafts: (logged_in_as_admin? ||
@work.user_is_owner_or_invited?(current_user))
)
unless @chapters.include?(@chapter)
access_denied
return
end
chapter_position = @chapters.index(@chapter)
if @chapters.length > 1
@previous_chapter = @chapters[chapter_position - 1] unless chapter_position.zero?
@next_chapter = @chapters[chapter_position + 1]
end
if @work.unrevealed?
@page_title = t(".unrevealed") + t(".chapter_position", position: @chapter.position.to_s)
else
fandoms = @tag_groups["Fandom"]
fandom = fandoms.empty? ? t(".unspecified_fandom") : fandoms[0].name
title_fandom = fandoms.size > 3 ? t(".multifandom") : fandom
author = @work.anonymous? ? t(".anonymous") : @work.pseuds.sort.collect(&:byline).join(", ")
@page_title = get_page_title(title_fandom, author, @work.title + t(".chapter_position", position: @chapter.position.to_s))
end
if params[:view_adult]
cookies[:view_adult] = "true"
elsif @work.adult? && !see_adult?
render "works/_adult", layout: "application" and return
end
@kudos = @work.kudos.with_user.includes(:user)
if current_user.respond_to?(:subscriptions)
@subscription = current_user.subscriptions.where(subscribable_id: @work.id,
subscribable_type: "Work").first ||
current_user.subscriptions.build(subscribable: @work)
end
# update the history.
Reading.update_or_create(@work, current_user) if current_user
respond_to do |format|
format.html
format.js
end
end
# GET /work/:work_id/chapters/new
# GET /work/:work_id/chapters/new.xml
def new
@chapter = @work.chapters.build(position: @work.number_of_chapters + 1)
end
# GET /work/:work_id/chapters/1/edit
def edit
return unless params["remove"] == "me"
@chapter.creatorships.for_user(current_user).destroy_all
if @work.chapters.any? { |c| current_user.is_author_of?(c) }
flash[:notice] = ts("You have been removed as a creator from the chapter.")
redirect_to @work
else # remove from work if no longer co-creator on any chapter
redirect_to edit_work_path(@work, remove: "me")
end
end
def draft_flash_message(work)
flash[:notice] = work.posted ? t("chapters.draft_flash.posted_work") : t("chapters.draft_flash.unposted_work_html", deletion_date: view_context.date_in_zone(work.created_at + 29.days)).html_safe
end
# POST /work/:work_id/chapters
# POST /work/:work_id/chapters.xml
def create
if params[:cancel_button]
redirect_back_or_default(root_path)
return
end
@chapter = @work.chapters.build(chapter_params)
@work.wip_length = params[:chapter][:wip_length]
if params[:edit_button] || chapter_cannot_be_saved?
render :new
else # :post_without_preview or :preview
@chapter.posted = true if params[:post_without_preview_button]
@work.set_revised_at_by_chapter(@chapter)
if @chapter.save && @work.save
if @chapter.posted
post_chapter
redirect_to [@work, @chapter]
else
draft_flash_message(@work)
redirect_to preview_work_chapter_path(@work, @chapter)
end
else
render :new
end
end
end
# PUT /work/:work_id/chapters/1
# PUT /work/:work_id/chapters/1.xml
def update
if params[:cancel_button]
# Not quite working yet - should send the user back to wherever they were before they hit edit
redirect_back_or_default(root_path)
return
end
@chapter.attributes = chapter_params
@work.wip_length = params[:chapter][:wip_length]
if params[:edit_button] || chapter_cannot_be_saved?
render :edit
elsif params[:preview_button]
@preview_mode = true
if @chapter.posted?
flash[:notice] = ts("This is a preview of what this chapter will look like after your changes have been applied. You should probably read the whole thing to check for problems before posting.")
else
draft_flash_message(@work)
end
render :preview
else
@chapter.posted = true if params[:post_button] || params[:post_without_preview_button]
posted_changed = @chapter.posted_changed?
@work.set_revised_at_by_chapter(@chapter)
if @chapter.save && @work.save
flash[:notice] = ts("Chapter was successfully #{posted_changed ? 'posted' : 'updated'}.")
redirect_to work_chapter_path(@work, @chapter)
else
render :edit
end
end
end
def update_positions
if params[:chapters]
@work.reorder_list(params[:chapters])
flash[:notice] = ts("Chapter order has been successfully updated.")
elsif params[:chapter]
params[:chapter].each_with_index do |id, position|
@work.chapters.update(id, position: position + 1)
(@chapters ||= []) << Chapter.find(id)
end
end
respond_to do |format|
format.html { redirect_to(@work) and return }
format.js { head :ok }
end
end
# GET /chapters/1/preview
def preview
@preview_mode = true
end
# POST /chapters/1/post
def post
if params[:cancel_button]
redirect_to @work
elsif params[:edit_button]
redirect_to [:edit, @work, @chapter]
else
@chapter.posted = true
@work.set_revised_at_by_chapter(@chapter)
if @chapter.save && @work.save
post_chapter
redirect_to(@work)
else
render :preview
end
end
end
# GET /work/:work_id/chapters/1/confirm_delete
def confirm_delete
end
# DELETE /work/:work_id/chapters/1
# DELETE /work/:work_id/chapters/1.xml
def destroy
if @chapter.is_only_chapter? || @chapter.only_non_draft_chapter?
flash[:error] = t(".only_chapter")
redirect_to(edit_work_path(@work))
return
end
was_draft = !@chapter.posted?
if @chapter.destroy
@work.minor_version = @work.minor_version + 1 unless was_draft
@work.set_revised_at
@work.save
flash[:notice] = ts("The chapter #{was_draft ? 'draft ' : ''}was successfully deleted.")
else
flash[:error] = ts("Something went wrong. Please try again.")
end
redirect_to controller: "works", action: "show", id: @work
end
private
# Check whether we should display :new or :edit instead of previewing or
# saving the user's changes.
def chapter_cannot_be_saved?
# The chapter can only be saved if the work can be saved:
if @work.invalid?
@work.errors.full_messages.each do |message|
@chapter.errors.add(:base, message)
end
end
@chapter.errors.any? || @chapter.invalid?
end
# fetch work these chapters belong to from db
def load_work
@work = params[:work_id] ? Work.find_by(id: params[:work_id]) : Chapter.find_by(id: params[:id]).try(:work)
if @work.blank?
flash[:error] = ts("Sorry, we couldn't find the work you were looking for.")
redirect_to root_path and return
end
@check_ownership_of = @work
@check_visibility_of = @work
end
# Loads the specified chapter from the database. Redirects to the work if no
# chapter is specified, or if the specified chapter doesn't exist.
def load_chapter
@chapter = @work.chapters.find_by(id: params[:id])
return if @chapter
flash[:error] = ts("Sorry, we couldn't find the chapter you were looking for.")
redirect_to work_path(@work)
end
def post_chapter
@work.update_attribute(:posted, true) unless @work.posted
flash[:notice] = ts("Chapter has been posted!")
end
private
def chapter_params
params.require(:chapter).permit(:title, :position, :wip_length, :"published_at(3i)",
:"published_at(2i)", :"published_at(1i)", :summary,
:notes, :endnotes, :content, :published_at,
author_attributes: [:byline, ids: [], coauthors: []])
end
end

View file

@ -0,0 +1,229 @@
class CollectionItemsController < ApplicationController
before_action :load_collection
before_action :load_user, only: [:update_multiple]
before_action :load_collectible_item, only: [:new, :create]
before_action :check_parent_visible, only: [:new]
before_action :users_only, only: [:new]
cache_sweeper :collection_sweeper
def index
# TODO: AO3-6507 Refactor to use send instead of case statements.
if @collection && @collection.user_is_maintainer?(current_user)
@collection_items = @collection.collection_items.include_for_works
@collection_items = case params[:status]
when "approved"
@collection_items.approved_by_both
when "rejected_by_collection"
@collection_items.rejected_by_collection
when "rejected_by_user"
@collection_items.rejected_by_user
when "unreviewed_by_user"
@collection_items.invited_by_collection
else
@collection_items.unreviewed_by_collection
end
elsif params[:user_id] && (@user = User.find_by(login: params[:user_id])) && @user == current_user
@collection_items = CollectionItem.for_user(@user).includes(:collection).merge(Collection.with_attached_icon)
@collection_items = case params[:status]
when "approved"
@collection_items.approved_by_both
when "rejected_by_collection"
@collection_items.rejected_by_collection
when "rejected_by_user"
@collection_items.rejected_by_user
when "unreviewed_by_collection"
@collection_items.approved_by_user.unreviewed_by_collection
else
@collection_items.unreviewed_by_user
end
else
flash[:error] = ts("You don't have permission to see that, sorry!")
redirect_to collections_path and return
end
sort = "created_at DESC"
@collection_items = @collection_items.order(sort).paginate page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE
end
def load_collectible_item
if params[:work_id]
@item = Work.find(params[:work_id])
elsif params[:bookmark_id]
@item = Bookmark.find(params[:bookmark_id])
end
end
def check_parent_visible
check_visibility_for(@item)
end
def load_user
unless @collection
@user = User.find_by(login: params[:user_id])
end
end
def new
end
def create
unless params[:collection_names]
flash[:error] = ts("What collections did you want to add?")
redirect_to(request.env["HTTP_REFERER"] || root_path) and return
end
unless @item
flash[:error] = ts("What did you want to add to a collection?")
redirect_to(request.env["HTTP_REFERER"] || root_path) and return
end
if !current_user.archivist && @item.respond_to?(:allow_collection_invitation?) && !@item.allow_collection_invitation?
flash[:error] = t(".invitation_not_sent", default: "This item could not be invited.")
redirect_to(@item) and return
end
# for each collection name
# see if it exists, is open, and isn't already one of this item's collections
# add the collection and save
# if there are errors, add them to errors
new_collections = []
invited_collections = []
unapproved_collections = []
errors = []
params[:collection_names].split(',').map {|name| name.strip}.uniq.each do |collection_name|
collection = Collection.find_by(name: collection_name)
if !collection
errors << ts("%{name}, because we couldn't find a collection with that name. Make sure you are using the one-word name, and not the title.", name: collection_name)
elsif @item.collections.include?(collection)
if @item.rejected_collections.include?(collection)
errors << ts("%{collection_title}, because the %{object_type}'s owner has rejected the invitation.", collection_title: collection.title, object_type: @item.class.name.humanize.downcase)
else
errors << ts("%{collection_title}, because this item has already been submitted.", collection_title: collection.title)
end
elsif collection.closed? && !collection.user_is_maintainer?(User.current_user)
errors << ts("%{collection_title} is closed to new submissions.", collection_title: collection.title)
elsif (collection.anonymous? || collection.unrevealed?) && !current_user.is_author_of?(@item)
errors << ts("%{collection_title}, because you don't own this item and the collection is anonymous or unrevealed.", collection_title: collection.title)
elsif !current_user.is_author_of?(@item) && !collection.user_is_maintainer?(current_user)
errors << ts("%{collection_title}, either you don't own this item or are not a moderator of the collection.", collection_title: collection.title)
elsif @item.is_a?(Work) && @item.anonymous? && !current_user.is_author_of?(@item)
errors << ts("%{collection_title}, because you don't own this item and the item is anonymous.", collection_title: collection.title)
# add the work to a collection, and try to save it
elsif @item.add_to_collection(collection) && @item.save(validate: false)
# approved_by_user? and approved_by_collection? are both true.
# This is will be true for archivists adding works to collections they maintain
# or creators adding their works to a collection with auto-approval.
if @item.approved_collections.include?(collection)
new_collections << collection
# if the current_user is a maintainer of the collection then approved_by_user must have been false (which means
# the current_user isn't the owner of the item), then the maintainer is attempting to invite this work to
# their collection
elsif collection.user_is_maintainer?(current_user)
invited_collections << collection
# otherwise the current_user is the owner of the item and approved_by_COLLECTION was false (which means the
# current_user isn't a collection_maintainer), so the item owner is attempting to add their work to a moderated
# collection
else
unapproved_collections << collection
end
else
errors << ts("Something went wrong trying to add collection %{name}, sorry!", name: collection_name)
end
end
# messages to the user
unless errors.empty?
flash[:error] = ts("We couldn't add your submission to the following collection(s): ") + "<br><ul><li />" + errors.join("<li />") + "</ul>"
end
flash[:notice] = "" unless new_collections.empty? && unapproved_collections.empty?
unless new_collections.empty?
flash[:notice] = ts("Added to collection(s): %{collections}.",
collections: new_collections.collect(&:title).join(", "))
end
unless invited_collections.empty?
invited_collections.each do |needs_user_approval|
flash[:notice] ||= ""
flash[:notice] = t(".invited_to_collections_html",
invited_link: view_context.link_to(t(".invited"),
collection_items_path(needs_user_approval, status: :unreviewed_by_user)),
collection_title: needs_user_approval.title)
end
end
unless unapproved_collections.empty?
flash[:notice] ||= ""
flash[:notice] += ts(" You have submitted your work to #{unapproved_collections.size > 1 ? "moderated collections (%{all_collections}). It will not become a part of those collections" : "the moderated collection '%{all_collections}'. It will not become a part of the collection"} until it has been approved by a moderator.", all_collections: unapproved_collections.map { |f| f.title }.join(', '))
end
flash[:notice] = (flash[:notice]).html_safe unless flash[:notice].blank?
flash[:error] = (flash[:error]).html_safe unless flash[:error].blank?
redirect_to(@item)
end
def update_multiple
if @collection&.user_is_maintainer?(current_user)
update_multiple_with_params(
allowed_items: @collection.collection_items,
update_params: collection_update_multiple_params,
success_path: collection_items_path(@collection)
)
elsif @user && @user == current_user
update_multiple_with_params(
allowed_items: CollectionItem.for_user(@user),
update_params: user_update_multiple_params,
success_path: user_collection_items_path(@user)
)
else
flash[:error] = ts("You don't have permission to do that, sorry!")
redirect_to(@collection || @user)
end
end
# The main work performed by update_multiple. Uses the passed-in parameters
# to update, and only updates items that can be found in allowed_items (which
# should be a relation on CollectionItems). When all items are successfully
# updated, redirects to success_path.
def update_multiple_with_params(allowed_items:, update_params:, success_path:)
# Collect any failures so that we can display errors:
@collection_items = []
# Make sure that the keys are integers so that we can look up the
# parameters by ID.
update_params.transform_keys!(&:to_i)
# By using where() here and updating each item individually, instead of
# using allowed_items.update(update_params.keys, update_params.values) --
# which uses find() under the hood -- we ensure that we'll fail silently if
# the user tries to update an item they're not allowed to.
allowed_items.where(id: update_params.keys).each do |item|
item_data = update_params[item.id]
if item_data[:remove] == "1"
next unless item.user_allowed_to_destroy?(current_user)
@collection_items << item unless item.destroy
else
@collection_items << item unless item.update(item_data)
end
end
if @collection_items.empty?
flash[:notice] = ts("Collection status updated!")
redirect_to success_path
else
render action: "index"
end
end
private
def user_update_multiple_params
allowed = %i[user_approval_status remove]
params.slice(:collection_items).permit(collection_items: allowed).
require(:collection_items)
end
def collection_update_multiple_params
allowed = %i[collection_approval_status unrevealed anonymous remove]
params.slice(:collection_items).permit(collection_items: allowed).
require(:collection_items)
end
end

View file

@ -0,0 +1,135 @@
class CollectionParticipantsController < ApplicationController
before_action :users_only
before_action :load_collection
before_action :load_participant, only: [:update, :destroy]
before_action :allowed_to_promote, only: [:update]
before_action :allowed_to_destroy, only: [:destroy]
before_action :has_other_owners, only: [:update, :destroy]
before_action :collection_maintainers_only, only: [:index, :add, :update]
cache_sweeper :collection_sweeper
def owners_required
flash[:error] = t("collection_participants.validation.owners_required")
redirect_to collection_participants_path(@collection)
false
end
def load_collection
@collection = Collection.find_by!(name: params[:collection_id])
end
def load_participant
@participant = @collection.collection_participants.find(params[:id])
end
def allowed_to_promote
@new_role = collection_participant_params[:participant_role]
@participant.user_allowed_to_promote?(current_user, @new_role) || not_allowed(@collection)
end
def allowed_to_destroy
@participant.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
end
def has_other_owners
!@participant.is_owner? || (@collection.owners != [@participant.pseud]) || owners_required
end
## ACTIONS
def join
unless @collection
flash[:error] = t('no_collection', default: "Which collection did you want to join?")
redirect_to(request.env["HTTP_REFERER"] || root_path) and return
end
participants = CollectionParticipant.in_collection(@collection).for_user(current_user) unless current_user.nil?
if participants.empty?
@participant = CollectionParticipant.new(
collection: @collection,
pseud: current_user.default_pseud,
participant_role: CollectionParticipant::NONE
)
@participant.save
flash[:notice] = t('applied_to_join_collection', default: "You have applied to join %{collection}.", collection: @collection.title)
else
participants.each do |participant|
if participant.is_invited?
participant.approve_membership!
flash[:notice] = t('collection_participants.accepted_invite', default: "You are now a member of %{collection}.", collection: @collection.title)
redirect_to(request.env["HTTP_REFERER"] || root_path) and return
end
end
flash[:notice] = t('collection_participants.no_invitation', default: "You have already joined (or applied to) this collection.")
end
redirect_to(request.env["HTTP_REFERER"] || root_path)
end
def index
@collection_participants = @collection.collection_participants.reject {|p| p.pseud.nil?}.sort_by {|participant| participant.pseud.name.downcase }
end
def update
if @participant.update(collection_participant_params)
flash[:notice] = t('collection_participants.update_success', default: "Updated %{participant}.", participant: @participant.pseud.name)
else
flash[:error] = t(".failure", participant: @participant.pseud.name)
end
redirect_to collection_participants_path(@collection)
end
def destroy
@participant.destroy
flash[:notice] = t('collection_participants.destroy', default: "Removed %{participant} from collection.", participant: @participant.pseud.name)
redirect_to(request.env["HTTP_REFERER"] || root_path)
end
def add
@participants_added = []
@participants_invited = []
pseud_results = Pseud.parse_bylines(params[:participants_to_invite])
pseud_results[:pseuds].each do |pseud|
if @collection.participants.include?(pseud)
participant = CollectionParticipant.where(collection_id: @collection.id, pseud_id: pseud.id).first
if participant && participant.is_none?
@participants_added << participant if participant.approve_membership!
end
else
participant = CollectionParticipant.new(collection: @collection, pseud: pseud, participant_role: CollectionParticipant::MEMBER)
@participants_invited << participant if participant.save
end
end
if @participants_invited.empty? && @participants_added.empty?
if pseud_results[:banned_pseuds].present?
flash[:error] = ts("%{name} cannot participate in challenges.",
name: pseud_results[:banned_pseuds].to_sentence
)
else
flash[:error] = ts("We couldn't find anyone new by that name to add.")
end
else
flash[:notice] = ""
end
unless @participants_invited.empty?
@participants_invited = @participants_invited.sort_by {|participant| participant.pseud.name.downcase }
flash[:notice] += ts("New members invited: ") + @participants_invited.collect(&:pseud).collect(&:byline).join(', ')
end
unless @participants_added.empty?
@participants_added = @participants_added.sort_by {|participant| participant.pseud.name.downcase }
flash[:notice] += ts("Members added: ") + @participants_added.collect(&:pseud).collect(&:byline).join(', ')
end
redirect_to collection_participants_path(@collection)
end
private
def collection_participant_params
params.require(:collection_participant).permit(:participant_role)
end
end

View file

@ -0,0 +1,13 @@
class CollectionProfileController < ApplicationController
before_action :load_collection
def show
unless @collection
flash[:error] = "What collection did you want to look at?"
redirect_to collections_path and return
end
@page_subtitle = t(".page_title", collection_title: @collection.title)
end
end

View file

@ -0,0 +1,214 @@
class CollectionsController < ApplicationController
before_action :users_only, only: [:new, :edit, :create, :update]
before_action :load_collection_from_id, only: [:show, :edit, :update, :destroy, :confirm_delete]
before_action :collection_owners_only, only: [:edit, :update, :destroy, :confirm_delete]
before_action :check_user_status, only: [:new, :create, :edit, :update, :destroy]
before_action :validate_challenge_type
before_action :check_parent_visible, only: [:index]
cache_sweeper :collection_sweeper
# Lazy fix to prevent passing unsafe values to eval via challenge_type
# In both CollectionsController#create and CollectionsController#update there are a vulnerable usages of eval
# For now just make sure the values passed to it are safe
def validate_challenge_type
if params[:challenge_type] and not ["", "GiftExchange", "PromptMeme"].include?(params[:challenge_type])
return render status: :bad_request, text: "invalid challenge_type"
end
end
def load_collection_from_id
@collection = Collection.find_by(name: params[:id])
unless @collection
raise ActiveRecord::RecordNotFound, "Couldn't find collection named '#{params[:id]}'"
end
end
def check_parent_visible
return unless params[:work_id] && (@work = Work.find_by(id: params[:work_id]))
check_visibility_for(@work)
end
def index
if params[:work_id]
@work = Work.find(params[:work_id])
@collections = @work.approved_collections
.by_title
.for_blurb
.paginate(page: params[:page])
elsif params[:collection_id]
@collection = Collection.find_by!(name: params[:collection_id])
@collections = @collection.children
.by_title
.for_blurb
.paginate(page: params[:page])
@page_subtitle = t(".subcollections_page_title", collection_title: @collection.title)
elsif params[:user_id]
@user = User.find_by!(login: params[:user_id])
@collections = @user.maintained_collections
.by_title
.for_blurb
.paginate(page: params[:page])
@page_subtitle = ts("%{username} - Collections", username: @user.login)
else
@sort_and_filter = true
params[:collection_filters] ||= {}
params[:sort_column] = "collections.created_at" if !valid_sort_column(params[:sort_column], 'collection')
params[:sort_direction] = 'DESC' if !valid_sort_direction(params[:sort_direction])
sort = params[:sort_column] + " " + params[:sort_direction]
@collections = Collection.sorted_and_filtered(sort, params[:collection_filters], params[:page])
end
end
# display challenges that are currently taking signups
def list_challenges
@page_subtitle = "Open Challenges"
@hide_dashboard = true
@challenge_collections = (Collection.signup_open("GiftExchange").limit(15) + Collection.signup_open("PromptMeme").limit(15))
end
def list_ge_challenges
@page_subtitle = "Open Gift Exchange Challenges"
@challenge_collections = Collection.signup_open("GiftExchange").limit(15)
end
def list_pm_challenges
@page_subtitle = "Open Prompt Meme Challenges"
@challenge_collections = Collection.signup_open("PromptMeme").limit(15)
end
def show
@page_subtitle = @collection.title
if @collection.collection_preference.show_random? || params[:show_random]
# show a random selection of works/bookmarks
@works = WorkQuery.new(
collection_ids: [@collection.id], show_restricted: is_registered_user?
).sample(count: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
@bookmarks = BookmarkQuery.new(
collection_ids: [@collection.id], show_restricted: is_registered_user?
).sample(count: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
else
# show recent
@works = WorkQuery.new(
collection_ids: [@collection.id], show_restricted: is_registered_user?,
sort_column: "revised_at",
per_page: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD
).search_results
@bookmarks = BookmarkQuery.new(
collection_ids: [@collection.id], show_restricted: is_registered_user?,
sort_column: "created_at",
per_page: ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD
).search_results
end
end
def new
@hide_dashboard = true
@collection = Collection.new
if params[:collection_id] && (@collection_parent = Collection.find_by(name: params[:collection_id]))
@collection.parent_name = @collection_parent.name
end
end
def edit
end
def create
@hide_dashboard = true
@collection = Collection.new(collection_params)
# add the owner
owner_attributes = []
(params[:owner_pseuds] || [current_user.default_pseud_id]).each do |pseud_id|
pseud = Pseud.find(pseud_id)
owner_attributes << {pseud: pseud, participant_role: CollectionParticipant::OWNER} if pseud
end
@collection.collection_participants.build(owner_attributes)
if @collection.save
flash[:notice] = ts('Collection was successfully created.')
unless params[:challenge_type].blank?
if params[:challenge_type] == "PromptMeme"
redirect_to new_collection_prompt_meme_path(@collection) and return
elsif params[:challenge_type] == "GiftExchange"
redirect_to new_collection_gift_exchange_path(@collection) and return
end
else
redirect_to collection_path(@collection)
end
else
@challenge_type = params[:challenge_type]
render action: "new"
end
end
def update
if @collection.update(collection_params)
flash[:notice] = ts('Collection was successfully updated.')
if params[:challenge_type].blank?
if @collection.challenge
# trying to destroy an existing challenge
flash[:error] = ts("Note: if you want to delete an existing challenge, please do so on the challenge page.")
end
else
if @collection.challenge
if @collection.challenge.class.name != params[:challenge_type]
flash[:error] = ts("Note: if you want to change the type of challenge, first please delete the existing challenge on the challenge page.")
else
if params[:challenge_type] == "PromptMeme"
redirect_to edit_collection_prompt_meme_path(@collection) and return
elsif params[:challenge_type] == "GiftExchange"
redirect_to edit_collection_gift_exchange_path(@collection) and return
end
end
else
if params[:challenge_type] == "PromptMeme"
redirect_to new_collection_prompt_meme_path(@collection) and return
elsif params[:challenge_type] == "GiftExchange"
redirect_to new_collection_gift_exchange_path(@collection) and return
end
end
end
redirect_to collection_path(@collection)
else
render action: "edit"
end
end
def confirm_delete
end
def destroy
@hide_dashboard = true
@collection = Collection.find_by(name: params[:id])
begin
@collection.destroy
flash[:notice] = ts("Collection was successfully deleted.")
rescue
flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
end
redirect_to(collections_path)
end
private
def collection_params
params.require(:collection).permit(
:name, :title, :email, :header_image_url, :description,
:parent_name, :challenge_type, :icon, :delete_icon,
:icon_alt_text, :icon_comment_text,
collection_profile_attributes: [
:id, :intro, :faq, :rules,
:gift_notification, :assignment_notification
],
collection_preference_attributes: [
:id, :moderated, :closed, :unrevealed, :anonymous,
:gift_exchange, :show_random, :prompt_meme, :email_notify
]
)
end
end

View file

@ -0,0 +1,700 @@
class CommentsController < ApplicationController
skip_before_action :store_location, except: [:show, :index, :new]
before_action :load_commentable,
only: [:index, :new, :create, :edit, :update, :show_comments,
:hide_comments, :add_comment_reply,
:cancel_comment_reply, :delete_comment,
:cancel_comment_delete, :unreviewed, :review_all]
before_action :check_user_status, only: [:new, :create, :edit, :update, :destroy]
before_action :load_comment, only: [:show, :edit, :update, :delete_comment, :destroy, :cancel_comment_edit, :cancel_comment_delete, :review, :approve, :reject, :freeze, :unfreeze, :hide, :unhide]
before_action :check_visibility, only: [:show]
before_action :check_if_restricted
before_action :check_tag_wrangler_access
before_action :check_parent_visible
before_action :check_modify_parent,
only: [:new, :create, :edit, :update, :add_comment_reply,
:cancel_comment_reply, :cancel_comment_edit]
before_action :check_pseud_ownership, only: [:create, :update]
before_action :check_ownership, only: [:edit, :update, :cancel_comment_edit]
before_action :check_permission_to_edit, only: [:edit, :update ]
before_action :check_permission_to_delete, only: [:delete_comment, :destroy]
before_action :check_guest_comment_admin_setting, only: [:new, :create, :add_comment_reply]
before_action :check_parent_comment_permissions, only: [:new, :create, :add_comment_reply]
before_action :check_unreviewed, only: [:add_comment_reply]
before_action :check_frozen, only: [:new, :create, :add_comment_reply]
before_action :check_hidden_by_admin, only: [:new, :create, :add_comment_reply]
before_action :check_not_replying_to_spam, only: [:new, :create, :add_comment_reply]
before_action :check_guest_replies_preference, only: [:new, :create, :add_comment_reply]
before_action :check_permission_to_review, only: [:unreviewed]
before_action :check_permission_to_access_single_unreviewed, only: [:show]
before_action :check_permission_to_moderate, only: [:approve, :reject]
before_action :check_permission_to_modify_frozen_status, only: [:freeze, :unfreeze]
before_action :check_permission_to_modify_hidden_status, only: [:hide, :unhide]
before_action :admin_logout_required, only: [:new, :create, :add_comment_reply]
include BlockHelper
before_action :check_blocked, only: [:new, :create, :add_comment_reply, :edit, :update]
def check_blocked
parent = find_parent
if blocked_by?(parent)
flash[:comment_error] = t("comments.check_blocked.parent")
redirect_to_all_comments(parent, show_comments: true)
elsif @comment && blocked_by_comment?(@comment.commentable)
# edit and update set @comment to the comment being edited
flash[:comment_error] = t("comments.check_blocked.reply")
redirect_to_all_comments(parent, show_comments: true)
elsif @comment.nil? && blocked_by_comment?(@commentable)
# new, create, and add_comment_reply don't set @comment, but do set @commentable
flash[:comment_error] = t("comments.check_blocked.reply")
redirect_to_all_comments(parent, show_comments: true)
end
end
def check_pseud_ownership
return unless params[:comment][:pseud_id]
pseud = Pseud.find(params[:comment][:pseud_id])
return if pseud && current_user && current_user.pseuds.include?(pseud)
flash[:error] = ts("You can't comment with that pseud.")
redirect_to root_path
end
def load_comment
@comment = Comment.find(params[:id])
@check_ownership_of = @comment
@check_visibility_of = @comment
end
def check_parent_visible
check_visibility_for(find_parent)
end
def check_modify_parent
parent = find_parent
# No one can create or update comments on something hidden by an admin.
if parent.respond_to?(:hidden_by_admin) && parent.hidden_by_admin
flash[:error] = ts("Sorry, you can't add or edit comments on a hidden work.")
redirect_to work_path(parent)
end
# No one can create or update comments on unrevealed works.
if parent.respond_to?(:in_unrevealed_collection) && parent.in_unrevealed_collection
flash[:error] = ts("Sorry, you can't add or edit comments on an unrevealed work.")
redirect_to work_path(parent)
end
end
def find_parent
if @comment.present?
@comment.ultimate_parent
elsif @commentable.is_a?(Comment)
@commentable.ultimate_parent
elsif @commentable.present? && @commentable.respond_to?(:work)
@commentable.work
else
@commentable
end
end
# Check to see if the ultimate_parent is a Work, and if so, if it's restricted
def check_if_restricted
parent = find_parent
return unless parent.respond_to?(:restricted) && parent.restricted? && !(logged_in? || logged_in_as_admin?)
redirect_to new_user_session_path(restricted_commenting: true)
end
# Check to see if the ultimate_parent is a Work or AdminPost, and if so, if it allows
# comments for the current user.
def check_parent_comment_permissions
parent = find_parent
if parent.is_a?(Work)
translation_key = "work"
elsif parent.is_a?(AdminPost)
translation_key = "admin_post"
else
return
end
if parent.disable_all_comments?
flash[:error] = t("comments.commentable.permissions.#{translation_key}.disable_all")
redirect_to parent
elsif parent.disable_anon_comments? && !logged_in?
flash[:error] = t("comments.commentable.permissions.#{translation_key}.disable_anon")
redirect_to parent
end
end
def check_guest_comment_admin_setting
admin_settings = AdminSetting.current
return unless admin_settings.guest_comments_off? && guest?
flash[:error] = t("comments.commentable.guest_comments_disabled")
redirect_back(fallback_location: root_path)
end
def check_guest_replies_preference
return unless guest? && @commentable.respond_to?(:guest_replies_disallowed?) && @commentable.guest_replies_disallowed?
flash[:error] = t("comments.check_guest_replies_preference.error")
redirect_back(fallback_location: root_path)
end
def check_unreviewed
return unless @commentable.respond_to?(:unreviewed?) && @commentable.unreviewed?
flash[:error] = ts("Sorry, you cannot reply to an unapproved comment.")
redirect_to logged_in? ? root_path : new_user_session_path
end
def check_frozen
return unless @commentable.respond_to?(:iced?) && @commentable.iced?
flash[:error] = t("comments.check_frozen.error")
redirect_back(fallback_location: root_path)
end
def check_hidden_by_admin
return unless @commentable.respond_to?(:hidden_by_admin?) && @commentable.hidden_by_admin?
flash[:error] = t("comments.check_hidden_by_admin.error")
redirect_back(fallback_location: root_path)
end
def check_not_replying_to_spam
return unless @commentable.respond_to?(:approved?) && !@commentable.approved?
flash[:error] = t("comments.check_not_replying_to_spam.error")
redirect_back(fallback_location: root_path)
end
def check_permission_to_review
parent = find_parent
return if logged_in_as_admin? || current_user_owns?(parent)
flash[:error] = ts("Sorry, you don't have permission to see those unreviewed comments.")
redirect_to logged_in? ? root_path : new_user_session_path
end
def check_permission_to_access_single_unreviewed
return unless @comment.unreviewed?
parent = find_parent
return if logged_in_as_admin? || current_user_owns?(parent) || current_user_owns?(@comment)
flash[:error] = ts("Sorry, that comment is currently in moderation.")
redirect_to logged_in? ? root_path : new_user_session_path
end
def check_permission_to_moderate
parent = find_parent
unless logged_in_as_admin? || current_user_owns?(parent)
flash[:error] = ts("Sorry, you don't have permission to moderate that comment.")
redirect_to(logged_in? ? root_path : new_user_session_path)
end
end
def check_tag_wrangler_access
if @commentable.is_a?(Tag) || (@comment&.parent&.is_a?(Tag))
logged_in_as_admin? || permit?("tag_wrangler") || access_denied
end
end
# Must be able to delete other people's comments on owned works, not just owned comments!
def check_permission_to_delete
access_denied(redirect: @comment) unless logged_in_as_admin? || current_user_owns?(@comment) || current_user_owns?(@comment.ultimate_parent)
end
# Comments cannot be edited after they've been replied to or if they are frozen.
def check_permission_to_edit
if @comment&.iced?
flash[:error] = t("comments.check_permission_to_edit.error.frozen")
redirect_back(fallback_location: root_path)
elsif !@comment&.count_all_comments&.zero?
flash[:error] = ts("Comments with replies cannot be edited")
redirect_back(fallback_location: root_path)
end
end
# Comments on works can be frozen or unfrozen by admins with proper
# authorization or the work creator.
# Comments on tags can be frozen or unfrozen by admins with proper
# authorization.
# Comments on admin posts can be frozen or unfrozen by any admin.
def check_permission_to_modify_frozen_status
return if permission_to_modify_frozen_status
# i18n-tasks-use t('comments.freeze.permission_denied')
# i18n-tasks-use t('comments.unfreeze.permission_denied')
flash[:error] = t("comments.#{action_name}.permission_denied")
redirect_back(fallback_location: root_path)
end
def check_permission_to_modify_hidden_status
return if policy(@comment).can_hide_comment?
# i18n-tasks-use t('comments.hide.permission_denied')
# i18n-tasks-use t('comments.unhide.permission_denied')
flash[:error] = t("comments.#{action_name}.permission_denied")
redirect_back(fallback_location: root_path)
end
# Get the thing the user is trying to comment on
def load_commentable
@thread_view = false
if params[:comment_id]
@thread_view = true
if params[:id]
@commentable = Comment.find(params[:id])
@thread_root = Comment.find(params[:comment_id])
else
@commentable = Comment.find(params[:comment_id])
@thread_root = @commentable
end
elsif params[:chapter_id]
@commentable = Chapter.find(params[:chapter_id])
elsif params[:work_id]
@commentable = Work.find(params[:work_id])
elsif params[:admin_post_id]
@commentable = AdminPost.find(params[:admin_post_id])
elsif params[:tag_id]
@commentable = Tag.find_by_name(params[:tag_id])
@page_subtitle = @commentable.try(:name)
end
end
def index
return raise_not_found if @commentable.blank?
return unless @commentable.class == Comment
# we link to the parent object at the top
@commentable = @commentable.ultimate_parent
end
def unreviewed
@comments = @commentable.find_all_comments
.unreviewed_only
.for_display
.page(params[:page])
end
# GET /comments/1
# GET /comments/1.xml
def show
@comments = CommentDecorator.wrap_comments([@comment])
@thread_view = true
@thread_root = @comment
params[:comment_id] = params[:id]
end
# GET /comments/new
def new
if @commentable.nil?
flash[:error] = ts("What did you want to comment on?")
redirect_back_or_default(root_path)
else
@comment = Comment.new
@controller_name = params[:controller_name] if params[:controller_name]
@name =
case @commentable.class.name
when /Work/
@commentable.title
when /Chapter/
@commentable.work.title
when /Tag/
@commentable.name
when /AdminPost/
@commentable.title
when /Comment/
ts("Previous Comment")
else
@commentable.class.name
end
end
end
# GET /comments/1/edit
def edit
respond_to do |format|
format.html
format.js
end
end
# POST /comments
# POST /comments.xml
def create
if @commentable.nil?
flash[:error] = ts("What did you want to comment on?")
redirect_back_or_default(root_path)
else
@comment = Comment.new(comment_params)
@comment.ip_address = request.remote_ip
@comment.user_agent = request.env["HTTP_USER_AGENT"]&.to(499)
@comment.commentable = Comment.commentable_object(@commentable)
@controller_name = params[:controller_name]
# First, try saving the comment
if @comment.save
flash[:comment_notice] = if @comment.unreviewed?
# i18n-tasks-use t("comments.create.success.moderated.admin_post")
# i18n-tasks-use t("comments.create.success.moderated.work")
t("comments.create.success.moderated.#{@comment.ultimate_parent.model_name.i18n_key}")
else
t("comments.create.success.not_moderated")
end
respond_to do |format|
format.html do
if request.referer&.match(/inbox/)
redirect_to user_inbox_path(current_user, filters: filter_params[:filters], page: params[:page])
elsif request.referer&.match(/new/) || (@comment.unreviewed? && current_user)
# If the referer is the new comment page, go to the comment's page
# instead of reloading the full work.
# If the comment is unreviewed and commenter is logged in, take
# them to the comment's page so they can access the edit and
# delete options for the comment, since unreviewed comments don't
# appear on the commentable.
redirect_to comment_path(@comment)
elsif request.referer == root_url
# replying on the homepage
redirect_to root_path
elsif @comment.unreviewed?
redirect_to_all_comments(@commentable)
else
redirect_to_comment(@comment, { view_full_work: (params[:view_full_work] == "true"), page: params[:page] })
end
end
end
else
flash[:error] = ts("Couldn't save comment!")
render action: "new"
end
end
end
# PUT /comments/1
# PUT /comments/1.xml
def update
updated_comment_params = comment_params.merge(edited_at: Time.current)
if @comment.update(updated_comment_params)
flash[:comment_notice] = ts('Comment was successfully updated.')
respond_to do |format|
format.html do
redirect_to comment_path(@comment) and return if @comment.unreviewed?
redirect_to_comment(@comment)
end
format.js # updating the comment in place
end
else
render action: "edit"
end
end
# DELETE /comments/1
# DELETE /comments/1.xml
def destroy
authorize @comment if logged_in_as_admin?
parent = @comment.ultimate_parent
parent_comment = @comment.reply_comment? ? @comment.commentable : nil
unreviewed = @comment.unreviewed?
if !@comment.destroy_or_mark_deleted
# something went wrong?
flash[:comment_error] = ts("We couldn't delete that comment.")
redirect_to_comment(@comment)
elsif unreviewed
# go back to the rest of the unreviewed comments
flash[:notice] = ts("Comment deleted.")
redirect_back(fallback_location: unreviewed_work_comments_path(@comment.commentable))
elsif parent_comment
flash[:comment_notice] = ts("Comment deleted.")
redirect_to_comment(parent_comment)
else
flash[:comment_notice] = ts("Comment deleted.")
redirect_to_all_comments(parent, {show_comments: true})
end
end
def review
if logged_in_as_admin?
authorize @comment
else
return unless current_user_owns?(@comment.ultimate_parent)
end
return unless @comment&.unreviewed?
@comment.toggle!(:unreviewed)
# mark associated inbox comments as read
InboxComment.where(user_id: current_user.id, feedback_comment_id: @comment.id).update_all(read: true) unless logged_in_as_admin?
flash[:notice] = ts("Comment approved.")
respond_to do |format|
format.html do
if params[:approved_from] == "inbox"
redirect_to user_inbox_path(current_user, page: params[:page], filters: filter_params[:filters])
elsif params[:approved_from] == "home"
redirect_to root_path
elsif @comment.ultimate_parent.is_a?(AdminPost)
redirect_to unreviewed_admin_post_comments_path(@comment.ultimate_parent)
else
redirect_to unreviewed_work_comments_path(@comment.ultimate_parent)
end
return
end
format.js
end
end
def review_all
authorize @commentable, policy_class: CommentPolicy if logged_in_as_admin?
unless (@commentable && current_user_owns?(@commentable)) || (@commentable && logged_in_as_admin? && @commentable.is_a?(AdminPost))
flash[:error] = ts("What did you want to review comments on?")
redirect_back_or_default(root_path)
return
end
@comments = @commentable.find_all_comments.unreviewed_only
@comments.each { |c| c.toggle!(:unreviewed) }
flash[:notice] = ts("All moderated comments approved.")
redirect_to @commentable
end
def approve
authorize @comment
@comment.mark_as_ham!
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
end
def reject
authorize @comment if logged_in_as_admin?
@comment.mark_as_spam!
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
end
# PUT /comments/1/freeze
def freeze
# TODO: When AO3-5939 is fixed, we can use
# comments = @comment.full_set
if @comment.iced?
flash[:comment_error] = t(".error")
else
comments = @comment.set_to_freeze_or_unfreeze
Comment.mark_all_frozen!(comments)
flash[:comment_notice] = t(".success")
end
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
rescue StandardError
flash[:comment_error] = t(".error")
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
end
# PUT /comments/1/unfreeze
def unfreeze
# TODO: When AO3-5939 is fixed, we can use
# comments = @comment.full_set
if @comment.iced?
comments = @comment.set_to_freeze_or_unfreeze
Comment.mark_all_unfrozen!(comments)
flash[:comment_notice] = t(".success")
else
flash[:comment_error] = t(".error")
end
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
rescue StandardError
flash[:comment_error] = t(".error")
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
end
# PUT /comments/1/hide
def hide
if !@comment.hidden_by_admin?
@comment.mark_hidden!
AdminActivity.log_action(current_admin, @comment, action: "hide comment")
flash[:comment_notice] = t(".success")
else
flash[:comment_error] = t(".error")
end
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
end
# PUT /comments/1/unhide
def unhide
if @comment.hidden_by_admin?
@comment.mark_unhidden!
AdminActivity.log_action(current_admin, @comment, action: "unhide comment")
flash[:comment_notice] = t(".success")
else
flash[:comment_error] = t(".error")
end
redirect_to_all_comments(@comment.ultimate_parent, show_comments: true)
end
def show_comments
respond_to do |format|
format.html do
# if non-ajax it could mean sudden javascript failure OR being redirected from login
# so we're being extra-nice and preserving any intention to comment along with the show comments option
options = {show_comments: true}
options[:add_comment_reply_id] = params[:add_comment_reply_id] if params[:add_comment_reply_id]
options[:view_full_work] = params[:view_full_work] if params[:view_full_work]
options[:page] = params[:page]
redirect_to_all_comments(@commentable, options)
end
format.js do
@comments = CommentDecorator.for_commentable(@commentable, page: params[:page])
end
end
end
def hide_comments
respond_to do |format|
format.html do
redirect_to_all_comments(@commentable)
end
format.js
end
end
# If JavaScript is enabled, use add_comment_reply.js to load the reply form
# Otherwise, redirect to a comment view with the form already loaded
def add_comment_reply
@comment = Comment.new
respond_to do |format|
format.html do
options = {show_comments: true}
options[:controller] = @commentable.class.to_s.underscore.pluralize
options[:anchor] = "comment_#{params[:id]}"
options[:page] = params[:page]
options[:view_full_work] = params[:view_full_work]
if @thread_view
options[:id] = @thread_root
options[:add_comment_reply_id] = params[:id]
redirect_to_comment(@commentable, options)
else
options[:id] = @commentable.id # work, chapter or other stuff that is not a comment
options[:add_comment_reply_id] = params[:id]
redirect_to_all_comments(@commentable, options)
end
end
format.js { @commentable = Comment.find(params[:id]) }
end
end
def cancel_comment_reply
respond_to do |format|
format.html do
options = {}
options[:show_comments] = params[:show_comments] if params[:show_comments]
redirect_to_all_comments(@commentable, options)
end
format.js { @commentable = Comment.find(params[:id]) }
end
end
def cancel_comment_edit
respond_to do |format|
format.html { redirect_to_comment(@comment) }
format.js
end
end
def delete_comment
respond_to do |format|
format.html do
options = {}
options[:show_comments] = params[:show_comments] if params[:show_comments]
options[:delete_comment_id] = params[:id] if params[:id]
redirect_to_comment(@comment, options) # TO DO: deleting without javascript doesn't work and it never has!
end
format.js
end
end
def cancel_comment_delete
respond_to do |format|
format.html do
options = {}
options[:show_comments] = params[:show_comments] if params[:show_comments]
redirect_to_comment(@comment, options)
end
format.js
end
end
protected
# redirect to a particular comment in a thread, going into the thread
# if necessary to display it
def redirect_to_comment(comment, options = {})
if comment.depth > ArchiveConfig.COMMENT_THREAD_MAX_DEPTH
if comment.ultimate_parent.is_a?(Tag)
default_options = {
controller: :comments,
action: :show,
id: comment.commentable.id,
tag_id: comment.ultimate_parent.to_param,
anchor: "comment_#{comment.id}"
}
else
default_options = {
controller: comment.commentable.class.to_s.underscore.pluralize,
action: :show,
id: (comment.commentable.is_a?(Tag) ? comment.commentable.to_param : comment.commentable.id),
anchor: "comment_#{comment.id}"
}
end
# display the comment's direct parent (and its associated thread)
redirect_to(url_for(default_options.merge(options)))
else
# need to redirect to the specific chapter; redirect_to_all will then retrieve full work view if applicable
redirect_to_all_comments(comment.parent, options.merge({show_comments: true, anchor: "comment_#{comment.id}"}))
end
end
def redirect_to_all_comments(commentable, options = {})
default_options = {anchor: "comments"}
options = default_options.merge(options)
if commentable.is_a?(Tag)
redirect_to comments_path(tag_id: commentable.to_param,
add_comment_reply_id: options[:add_comment_reply_id],
delete_comment_id: options[:delete_comment_id],
page: options[:page],
anchor: options[:anchor])
else
if commentable.is_a?(Chapter) && (options[:view_full_work] || current_user.try(:preference).try(:view_full_works))
commentable = commentable.work
end
redirect_to polymorphic_path(commentable,
options.slice(:show_comments,
:add_comment_reply_id,
:delete_comment_id,
:view_full_work,
:anchor,
:page))
end
end
def permission_to_modify_frozen_status
parent = find_parent
return true if policy(@comment).can_freeze_comment?
return true if parent.is_a?(Work) && current_user_owns?(parent)
false
end
private
def comment_params
params.require(:comment).permit(
:pseud_id, :comment_content, :name, :email, :edited_at
)
end
def filter_params
params.permit!
end
end

View file

@ -0,0 +1,14 @@
module TagWrangling
extend ActiveSupport::Concern
# When used an around_action, record_wrangling_activity will
# mark that some wrangling activity has occurred. If a Wrangleable
# is saved during the action, a LastWranglingActivity will be
# recorded. Otherwise no additional steps are taken.
def record_wrangling_activity
User.should_update_wrangling_activity = true
yield
ensure
User.should_update_wrangling_activity = false
end
end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
# A controller for viewing co-creator requests -- that is, creatorships where
# the creator hasn't yet approved it.
class CreatorshipsController < ApplicationController
before_action :load_user
before_action :check_ownership
# Show all of the creatorships associated with the current user. Displays a
# form where the user can select multiple creatorships and perform actions
# (accept, reject) in bulk.
def show
@page_subtitle = ts("Co-Creator Requests")
@creatorships = @creatorships.unapproved.order(id: :desc).
paginate(page: params[:page])
end
# Update the selected creatorships.
def update
@creatorships = @creatorships.where(id: params[:selected])
if params[:accept]
accept_update
elsif params[:reject]
reject_update
end
redirect_to user_creatorships_path(@user, page: params[:page])
end
private
# When the user presses "Accept" on the co-creator request listing, this is
# the code that runs.
def accept_update
flash[:notice] = []
@creatorships.each do |creatorship|
creatorship.accept!
link = view_context.link_to(title_for_creation(creatorship.creation),
creatorship.creation)
flash[:notice] << ts("You are now listed as a co-creator on %{link}.",
link: link).html_safe
end
end
# When the user presses "Reject" on the co-creator request listing, this is
# the code that runs. Note that rejection is equivalent to destroying the
# request.
def reject_update
@creatorships.each(&:destroy)
flash[:notice] = ts("Requests rejected.")
end
# A helper method used to display a nicely formatted title for a creation.
helper_method :title_for_creation
def title_for_creation(creation)
if creation.is_a?(Chapter)
"Chapter #{creation.position} of #{creation.work.title}"
else
creation.title
end
end
# Load the user, and set @creatorships equal to all co-creator requests for
# that user.
def load_user
@user = User.find_by!(login: params[:user_id])
@check_ownership_of = @user
@creatorships = Creatorship.unapproved.for_user(@user)
end
end

View file

@ -0,0 +1,72 @@
class DownloadsController < ApplicationController
skip_before_action :store_location, only: :show
before_action :load_work, only: :show
before_action :check_download_posted_status, only: :show
before_action :check_download_visibility, only: :show
around_action :remove_downloads, only: :show
def show
respond_to :html, :pdf, :mobi, :epub, :azw3
@download = Download.new(@work, mime_type: request.format)
@download.generate
# Make sure we were able to generate the download.
unless @download.exists?
flash[:error] = ts("We were not able to render this work. Please try again in a little while or try another format.")
redirect_to work_path(@work)
return
end
# Send file synchronously so we don't delete it before we have finished
# sending it
File.open(@download.file_path, 'r') do |f|
send_data f.read, filename: "#{@download.file_name}.#{@download.file_type}", type: @download.mime_type
end
end
protected
# Set up the work and check revealed status
# Once a format has been created, we want nginx to be able to serve
# it directly, without going through Rails again (until the work changes).
# This means no processing per user. Consider this the "published" version.
# It can't contain unposted chapters, nor unrevealed creators, even
# if the creator is the one requesting the download.
def load_work
unless AdminSetting.current.downloads_enabled?
flash[:error] = ts("Sorry, downloads are currently disabled.")
redirect_back_or_default works_path
return
end
@work = Work.find(params[:id])
end
# We're currently just writing everything to tmp and feeding them through
# nginx so we don't want to keep the files around.
def remove_downloads
yield
ensure
@download.remove
end
# We can't use check_visibility because this controller doesn't have access to
# cookies on production or staging.
def check_download_visibility
return unless @work.hidden_by_admin || @work.in_unrevealed_collection?
message = if @work.hidden_by_admin
ts("Sorry, you can't download a work that has been hidden by an admin.")
else
ts("Sorry, you can't download an unrevealed work.")
end
flash[:error] = message
redirect_to work_path(@work)
end
def check_download_posted_status
return if @work.posted
flash[:error] = ts("Sorry, you can't download a draft.")
redirect_to work_path(@work)
end
end

View file

@ -0,0 +1,16 @@
class ErrorsController < ApplicationController
%w[403 404 422 500].each do |error_code|
define_method error_code.to_sym do
render error_code, status: error_code.to_i, formats: :html
end
end
def auth_error
@page_subtitle = t(".browser_title")
end
def timeout_error
@page_subtitle = t(".browser_title")
render "timeout_error", status: :gateway_timeout
end
end

View file

@ -0,0 +1,104 @@
class ExternalAuthorsController < ApplicationController
before_action :load_user
before_action :check_ownership, only: [:edit]
before_action :check_user_status, only: [:edit]
before_action :get_external_author_from_invitation, only: [:claim, :complete_claim]
before_action :users_only, only: [:complete_claim]
def load_user
@user = User.find_by(login: params[:user_id])
@check_ownership_of = @user
end
def index
if @user && current_user == @user
@external_authors = @user.external_authors
elsif logged_in? && current_user.archivist
@external_authors = ExternalCreatorship.where(archivist_id: current_user).collect(&:external_author).uniq
elsif logged_in?
redirect_to user_external_authors_path(current_user)
else
flash[:notice] = "You can't see that information."
redirect_to root_path
end
end
def edit
@external_author = ExternalAuthor.find(params[:id])
end
def get_external_author_from_invitation
token = params[:invitation_token] || (params[:user] && params[:user][:invitation_token])
@invitation = Invitation.find_by(token: token)
unless @invitation
flash[:error] = ts("You need an invitation to do that.")
redirect_to root_path
return
end
@external_author = @invitation.external_author
return if @external_author
flash[:error] = ts("There are no stories to claim on this invitation. Did you want to sign up instead?")
redirect_to signup_path(@invitation.token)
end
def claim
end
def complete_claim
# go ahead and give the user the works
@external_author.claim!(current_user)
@invitation.mark_as_redeemed(current_user) if @invitation
flash[:notice] = t('external_author_claimed', default: "We have added the stories imported under %{email} to your account.", email: @external_author.email)
redirect_to user_external_authors_path(current_user)
end
def update
@invitation = Invitation.find_by(token: params[:invitation_token])
@external_author = ExternalAuthor.find(params[:id])
unless (@invitation && @invitation.external_author == @external_author) || @external_author.user == current_user
flash[:error] = "You don't have permission to do that."
redirect_to root_path
return
end
flash[:notice] = ""
if params[:imported_stories] == "nothing"
flash[:notice] += "Okay, we'll leave things the way they are! You can use the email link any time if you change your mind. "
elsif params[:imported_stories] == "orphan"
# orphan the works
@external_author.orphan(params[:remove_pseud])
flash[:notice] += "Your imported stories have been orphaned. Thank you for leaving them in the archive! "
elsif params[:imported_stories] == "delete"
# delete the works
@external_author.delete_works
flash[:notice] += "Your imported stories have been deleted. "
end
if @invitation &&
params[:imported_stories].present? &&
params[:imported_stories] != "nothing"
@invitation.mark_as_redeemed
end
if @external_author.update(external_author_params[:external_author] || {})
flash[:notice] += "Your preferences have been saved."
redirect_to @user ? user_external_authors_path(@user) : root_path
else
flash[:error] = "There were problems saving your preferences."
render action: "edit"
end
end
private
def external_author_params
params.permit(
:id, :user_id, :utf8, :_method, :authenticity_token, :invitation_token,
:imported_stories, :commit, :remove_pseud,
external_author: [
:email, :do_not_email, :do_not_import
]
)
end
end

View file

@ -0,0 +1,69 @@
class ExternalWorksController < ApplicationController
before_action :admin_only, only: [:edit, :update]
before_action :users_only, only: [:new]
before_action :check_user_status, only: [:new]
def new
@bookmarkable = ExternalWork.new
@bookmark = Bookmark.new
end
# Used with bookmark form to get an existing external work and return it via ajax
def fetch
if params[:external_work_url]
url = Addressable::URI.heuristic_parse(params[:external_work_url]).to_str
@external_work = ExternalWork.where(url: url).first
end
respond_to do |format|
format.js
end
end
def index
if params[:show] == "duplicates"
unless logged_in_as_admin?
access_denied
return
end
@external_works = ExternalWork.duplicate.order("created_at DESC").paginate(page: params[:page])
else
@external_works = ExternalWork.order("created_at DESC").paginate(page: params[:page])
end
end
def show
@external_work = ExternalWork.find(params[:id])
end
def edit
@external_work = authorize ExternalWork.find(params[:id])
@work = @external_work
end
def update
@external_work = authorize ExternalWork.find(params[:id])
@external_work.attributes = work_params
if @external_work.update(external_work_params)
flash[:notice] = t(".successfully_updated")
redirect_to(@external_work)
else
render action: "edit"
end
end
private
def external_work_params
params.require(:external_work).permit(
:url, :author, :title, :summary, :language_id
)
end
def work_params
params.require(:work).permit(
:rating_string, :fandom_string, :relationship_string, :character_string,
:freeform_string, category_strings: [], archive_warning_strings: []
)
end
end

View file

@ -0,0 +1,56 @@
class FandomsController < ApplicationController
before_action :load_collection
def index
if @collection
@media = Media.canonical.by_name - [Media.find_by(name: ArchiveConfig.MEDIA_NO_TAG_NAME)] - [Media.find_by(name: ArchiveConfig.MEDIA_UNCATEGORIZED_NAME)]
@page_subtitle = t(".collection_page_title", collection_title: @collection.title)
@medium = Media.find_by_name(params[:media_id]) if params[:media_id]
@counts = SearchCounts.fandom_ids_for_collection(@collection)
@fandoms = (@medium ? @medium.fandoms : Fandom.all).where(id: @counts.keys).by_name
elsif params[:media_id]
@medium = Media.find_by_name!(params[:media_id])
@page_subtitle = @medium.name
@fandoms = if @medium == Media.uncategorized
@medium.fandoms.in_use.by_name
else
@medium.fandoms.canonical.by_name.with_count
end
else
flash[:notice] = t(".choose_media")
redirect_to media_index_path and return
end
@fandoms_by_letter = @fandoms.group_by { |f| f.sortable_name[0].upcase }
end
def show
@fandom = Fandom.find_by_name(params[:id])
if @fandom.nil?
flash[:error] = ts("Could not find fandom named %{fandom_name}", fandom_name: params[:id])
redirect_to media_index_path and return
end
@characters = @fandom.characters.canonical.by_name
end
def unassigned
join_string = "LEFT JOIN wrangling_assignments
ON (wrangling_assignments.fandom_id = tags.id)
LEFT JOIN users
ON (users.id = wrangling_assignments.user_id)"
conditions = "canonical = 1 AND users.id IS NULL"
unless params[:media_id].blank?
@media = Media.find_by_name(params[:media_id])
if @media
join_string << " INNER JOIN common_taggings
ON (tags.id = common_taggings.common_tag_id)"
conditions << " AND common_taggings.filterable_id = #{@media.id}
AND common_taggings.filterable_type = 'Tag'"
end
end
@fandoms = Fandom.joins(join_string).
where(conditions).
order(params[:sort] == 'count' ? "count DESC" : "sortable_name ASC").
with_count.
paginate(page: params[:page], per_page: 250)
end
end

View file

@ -0,0 +1,52 @@
class FavoriteTagsController < ApplicationController
skip_before_action :store_location, only: [:create, :destroy]
before_action :users_only
before_action :load_user
before_action :check_ownership
respond_to :html, :json
# POST /favorites_tags
def create
@favorite_tag = current_user.favorite_tags.build(favorite_tag_params)
success_message = ts("You have successfully added %{tag_name} to your favorite tags. You can find them on the <a href='#{root_path}'>Archive homepage</a>.", tag_name: @favorite_tag.tag_name)
if @favorite_tag.save
respond_to do |format|
format.html { redirect_to tag_works_path(tag_id: @favorite_tag.tag.to_param), notice: success_message.html_safe }
format.json { render json: { item_id: @favorite_tag.id, item_success_message: success_message }, status: :created }
end
else
respond_to do |format|
format.html do
flash.keep
redirect_to tag_works_path(tag_id: @favorite_tag.tag.to_param), flash: { error: @favorite_tag.errors.full_messages }
end
format.json { render json: { errors: @favorite_tag.errors.full_messages }, status: :unprocessable_entity }
end
end
end
# DELETE /favorite_tags/1
def destroy
@favorite_tag = FavoriteTag.find(params[:id])
@favorite_tag.destroy
success_message = ts('You have successfully removed %{tag_name} from your favorite tags.', tag_name: @favorite_tag.tag_name)
respond_to do |format|
format.html { redirect_to tag_works_path(tag_id: @favorite_tag.tag.to_param), notice: success_message }
format.json { render json: { item_success_message: success_message }, status: :ok }
end
end
private
def load_user
@user = User.find_by(login: params[:user_id])
@check_ownership_of = @user
end
def favorite_tag_params
params.require(:favorite_tag).permit(
:tag_id
)
end
end

View file

@ -0,0 +1,48 @@
class FeedbacksController < ApplicationController
skip_before_action :store_location
before_action :load_support_languages
def new
@admin_setting = AdminSetting.current
@feedback = Feedback.new
@feedback.referer = request.referer
if logged_in_as_admin?
@feedback.email = current_admin.email
elsif is_registered_user?
@feedback.email = current_user.email
@feedback.username = current_user.login
end
end
def create
@admin_setting = AdminSetting.current
@feedback = Feedback.new(feedback_params)
@feedback.rollout = @feedback.rollout_string
@feedback.user_agent = request.env["HTTP_USER_AGENT"]&.to(499)
@feedback.ip_address = request.remote_ip
@feedback.referer = nil unless @feedback.referer && ArchiveConfig.PERMITTED_HOSTS.include?(URI(@feedback.referer).host)
@feedback.site_skin = helpers.current_skin
if @feedback.save
@feedback.email_and_send
flash[:notice] = t("successfully_sent",
default: "Your message was sent to the Archive team - thank you!")
redirect_back_or_default(root_path)
else
flash[:error] = t("failure_send",
default: "Sorry, your message could not be saved - please try again!")
render action: "new"
end
end
private
def load_support_languages
@support_languages = Language.where(support_available: true).default_order
end
def feedback_params
params.require(:feedback).permit(
:comment, :email, :summary, :username, :language, :referer
)
end
end

View file

@ -0,0 +1,61 @@
class GiftsController < ApplicationController
before_action :load_collection
def index
@user = User.find_by!(login: params[:user_id]) if params[:user_id]
@recipient_name = params[:recipient]
@page_subtitle = ts("Gifts for %{name}", name: (@user ? @user.login : @recipient_name))
unless @user || @recipient_name
flash[:error] = ts("Whose gifts did you want to see?")
redirect_to(@collection || root_path) and return
end
if @user
if current_user.nil?
@works = @user.gift_works.visible_to_all
else
if @user == current_user && params[:refused]
@works = @user.rejected_gift_works.visible_to_registered_user
else
@works = @user.gift_works.visible_to_registered_user
end
end
else
pseud = Pseud.parse_byline(@recipient_name)
if pseud
if current_user.nil?
@works = pseud.gift_works.visible_to_all
else
@works = pseud.gift_works.visible_to_registered_user
end
else
if current_user.nil?
@works = Work.giftworks_for_recipient_name(@recipient_name).visible_to_all
else
@works = Work.giftworks_for_recipient_name(@recipient_name).visible_to_registered_user
end
end
end
@works = @works.in_collection(@collection) if @collection
@works = @works.order('revised_at DESC').paginate(page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE)
end
def toggle_rejected
@gift = Gift.find(params[:id])
# have to have the gift, be logged in, and the owner of the gift
if @gift && current_user && @gift.user == current_user
@gift.rejected = !@gift.rejected?
@gift.save!
if @gift.rejected?
flash[:notice] = ts("This work will no longer be listed among your gifts.")
else
flash[:notice] = ts("This work will now be listed among your gifts.")
end
else
# user doesn't have permission
access_denied
return
end
redirect_to user_gifts_path(current_user) and return
end
end

View file

@ -0,0 +1,31 @@
# This controller is exclusively used to track hit counts on works.
#
# Note that we deliberately only accept JSON requests because JSON requests
# bypass a lot of the usual before_action hooks, many of which can trigger
# database queries. We want this action to avoid hitting the database if at
# all possible.
class HitCountController < ApplicationController
skip_around_action :set_current_user
skip_before_action :logout_if_not_user_credentials
skip_after_action :ensure_user_credentials,
:ensure_admin_credentials
def create
respond_to do |format|
format.json do
if ENV["REQUEST_FROM_BOT"]
head :forbidden
else
RedisHitCounter.add(
params[:work_id].to_i,
request.remote_ip
)
head :ok
end
end
end
end
end

View file

@ -0,0 +1,102 @@
class HomeController < ApplicationController
before_action :users_only, only: [:first_login_help]
skip_before_action :store_location, only: [:first_login_help, :token_dispenser]
# unicorn_test
def unicorn_test
end
def content
@page_subtitle = t(".page_title")
render action: "content", layout: "application"
end
def privacy
@page_subtitle = t(".page_title")
render action: "privacy", layout: "application"
end
# terms of service
def tos
@page_subtitle = t(".page_title")
render action: "tos", layout: "application"
end
# terms of service faq
def tos_faq
@page_subtitle = t(".page_title")
render action: "tos_faq", layout: "application"
end
# dmca policy
def dmca
render action: "dmca", layout: "application"
end
# lost cookie
def lost_cookie
render action: 'lost_cookie', layout: 'application'
end
# for updating form tokens on cached pages
def token_dispenser
respond_to do |format|
format.json { render json: { token: form_authenticity_token } }
end
end
# diversity statement
def diversity
render action: "diversity_statement", layout: "application"
end
# site map
def site_map
render action: "site_map", layout: "application"
end
# donate
def donate
@page_subtitle = t(".page_title")
render action: "donate", layout: "application"
end
# about
def about
@page_subtitle = t(".page_title")
render action: "about", layout: "application"
end
def first_login_help
render action: "first_login_help", layout: false
end
# home page itself
#def index
#@homepage = Homepage.new(@current_user)
#unless @homepage.logged_in?
#@user_count, @work_count, @fandom_count = @homepage.rounded_counts
#end
#@hide_dashboard = true
#render action: 'index', layout: 'application'
#end
#end
#commenting out old index controller so i can add in random user lol
# replace index at the bottom with this code
def index
@homepage = Homepage.new(@current_user)
@random_user = User.unscoped.order(Arel.sql("RAND()")).first
unless @homepage.logged_in?
@user_count, @work_count, @fandom_count = @homepage.rounded_counts
end
@hide_dashboard = true
render action: 'index', layout: 'application'
end
end

View file

@ -0,0 +1,75 @@
class InboxController < ApplicationController
include BlockHelper
before_action :load_user
before_action :check_ownership_or_admin
before_action :load_commentable, only: :reply
before_action :check_blocked, only: :reply
def load_user
@user = User.find_by(login: params[:user_id])
@check_ownership_of = @user
end
def show
authorize InboxComment if logged_in_as_admin?
@page_subtitle = t(".page_title", user: @user.login)
@inbox_total = @user.inbox_comments.with_bad_comments_removed.count
@unread = @user.inbox_comments.with_bad_comments_removed.count_unread
@filters = filter_params[:filters] || {}
@inbox_comments = @user.inbox_comments.with_bad_comments_removed.find_by_filters(@filters).page(params[:page])
end
def reply
@comment = Comment.new
respond_to do |format|
format.html do
redirect_to comment_path(@commentable, add_comment_reply_id: @commentable.id, anchor: 'comment_' + @commentable.id.to_s)
end
format.js
end
end
def update
authorize InboxComment if logged_in_as_admin?
begin
@inbox_comments = InboxComment.find(params[:inbox_comments])
if params[:read]
@inbox_comments.each { |i| i.update_attribute(:read, true) }
elsif params[:unread]
@inbox_comments.each { |i| i.update_attribute(:read, false) }
elsif params[:delete]
@inbox_comments.each { |i| i.destroy }
end
success_message = t(".success")
rescue
flash[:caution] = t(".must_select_item")
end
respond_to do |format|
format.html { redirect_to request.referer || user_inbox_path(@user, page: params[:page], filters: params[:filters]), notice: success_message }
format.json { render json: { item_success_message: success_message }, status: :ok }
end
end
private
# Allow flexible params through, since we're not posting any data
def filter_params
params.permit!
end
def load_commentable
@commentable = Comment.find(params[:comment_id])
end
def check_blocked
if blocked_by?(@commentable.ultimate_parent)
flash[:error] = t("comments.check_blocked.parent")
redirect_back(fallback_location: user_inbox_path(@user))
elsif blocked_by_comment?(@commentable)
flash[:error] = t("comments.check_blocked.reply")
redirect_back(fallback_location: user_inbox_path(@user))
end
end
end

View file

@ -0,0 +1,93 @@
class InvitationsController < ApplicationController
before_action :check_permission
before_action :admin_only, only: [:create, :destroy]
before_action :check_user_status, only: [:index, :manage, :invite_friend, :update]
before_action :load_invitation, only: [:show, :invite_friend, :update, :destroy]
before_action :check_ownership_or_admin, only: [:show, :invite_friend, :update]
def load_invitation
@invitation = Invitation.find(params[:id] || invitation_params[:id])
@check_ownership_of = @invitation
end
def check_permission
@user = User.find_by(login: params[:user_id])
access_denied unless policy(User).can_manage_users? || @user.present? && @user == current_user
end
def index
@unsent_invitations = @user.invitations.unsent.limit(5)
end
def manage
status = params[:status]
@invitations = @user.invitations
if %w(unsent unredeemed redeemed).include?(status)
@invitations = @invitations.send(status)
end
end
def show
end
def invite_friend
if !invitation_params[:invitee_email].blank?
@invitation.invitee_email = invitation_params[:invitee_email]
if @invitation.save
flash[:notice] = 'Invitation was successfully sent.'
redirect_to([@user, @invitation])
else
render action: "show"
end
else
flash[:error] = "Please enter an email address."
render action: "show"
end
end
def create
if invitation_params[:number_of_invites].to_i > 0
invitation_params[:number_of_invites].to_i.times do
@user.invitations.create
end
end
flash[:notice] = "Invitations were successfully created."
redirect_to user_invitations_path(@user)
end
def update
@invitation.attributes = invitation_params
if @invitation.invitee_email_changed? && @invitation.update(invitation_params)
flash[:notice] = 'Invitation was successfully sent.'
if logged_in_as_admin?
redirect_to find_admin_invitations_path("invitation[token]" => @invitation.token)
else
redirect_to([@user, @invitation])
end
else
flash[:error] = "Please enter an email address." if @invitation.invitee_email.blank?
render action: "show"
end
end
def destroy
@user = @invitation.creator
if @invitation.destroy
flash[:notice] = "Invitation successfully destroyed"
else
flash[:error] = "Invitation was not destroyed."
end
if @user.is_a?(User)
redirect_to user_invitations_path(@user)
else
redirect_to admin_invitations_path
end
end
private
def invitation_params
params.require(:invitation).permit(:id, :invitee_email, :number_of_invites)
end
end

View file

@ -0,0 +1,124 @@
class InviteRequestsController < ApplicationController
before_action :admin_only, only: [:manage, :destroy]
# GET /invite_requests
# Set browser page title to Invitation Requests
def index
@invite_request = InviteRequest.new
@page_subtitle = t(".page_title")
end
# GET /invite_requests/1
def show
@invite_request = InviteRequest.find_by(email: params[:email])
if @invite_request.present?
@position_in_queue = @invite_request.position
else
@invitation = Invitation.unredeemed.from_queue.find_by(invitee_email: params[:email])
end
respond_to do |format|
format.html
format.js
end
end
def resend
@invitation = Invitation.unredeemed.from_queue.find_by(invitee_email: params[:email])
if @invitation.nil?
flash[:error] = t("invite_requests.resend.not_found")
elsif !@invitation.can_resend?
flash[:error] = t("invite_requests.resend.not_yet",
count: ArchiveConfig.HOURS_BEFORE_RESEND_INVITATION)
else
@invitation.send_and_set_date(resend: true)
if @invitation.errors.any?
flash[:error] = @invitation.errors.full_messages.first
else
flash[:notice] = t("invite_requests.resend.success", email: @invitation.invitee_email)
end
end
redirect_to status_invite_requests_path
end
# POST /invite_requests
def create
unless AdminSetting.current.invite_from_queue_enabled?
flash[:error] = t(".queue_disabled.html",
closed_bold: helpers.tag.strong(t("invite_requests.create.queue_disabled.closed")),
news_link: helpers.link_to(t("invite_requests.create.queue_disabled.news"), admin_posts_path(tag: 143)))
redirect_to invite_requests_path
return
end
@invite_request = InviteRequest.new(invite_request_params)
@invite_request.ip_address = request.remote_ip
if @invite_request.save
flash[:notice] = t(".success",
date: l(@invite_request.proposed_fill_time.to_date, format: :long),
return_address: ArchiveConfig.RETURN_ADDRESS)
redirect_to invite_requests_path
else
render action: :index
end
end
def manage
authorize(InviteRequest)
@invite_requests = InviteRequest.all
if params[:query].present?
query = "%#{params[:query]}%"
@invite_requests = InviteRequest.where(
"simplified_email LIKE ? OR ip_address LIKE ?",
query, query
)
# Keep track of the fact that this has been filtered, so the position
# will not cleanly correspond to the page that we're on and the index of
# the request on the page:
@filtered = true
end
@invite_requests = @invite_requests.order(:id).page(params[:page])
end
def destroy
@invite_request = InviteRequest.find(params[:id])
authorize @invite_request
if @invite_request.destroy
success_message = ts("Request for %{email} was removed from the queue.", email: @invite_request.email)
respond_to do |format|
format.html { redirect_to manage_invite_requests_path(page: params[:page], query: params[:query]), notice: success_message }
format.json { render json: { item_success_message: success_message }, status: :ok }
end
else
error_message = ts("Request could not be removed. Please try again.")
respond_to do |format|
format.html do
flash.keep
redirect_to manage_invite_requests_path(page: params[:page], query: params[:query]), flash: { error: error_message }
end
format.json { render json: { errors: error_message }, status: :unprocessable_entity }
end
end
end
def status
@page_subtitle = t(".browser_title")
end
private
def invite_request_params
params.require(:invite_request).permit(
:email, :query
)
end
end

View file

@ -0,0 +1,58 @@
class KnownIssuesController < ApplicationController
before_action :admin_only, except: [:index]
# GET /known_issues
def index
@known_issues = KnownIssue.all
end
# GET /known_issues/1
def show
@known_issue = authorize KnownIssue.find(params[:id])
end
# GET /known_issues/new
def new
@known_issue = authorize KnownIssue.new
end
# GET /known_issues/1/edit
def edit
@known_issue = authorize KnownIssue.find(params[:id])
end
# POST /known_issues
def create
@known_issue = authorize KnownIssue.new(known_issue_params)
if @known_issue.save
flash[:notice] = "Known issue was successfully created."
redirect_to(@known_issue)
else
render action: "new"
end
end
# PUT /known_issues/1
def update
@known_issue = authorize KnownIssue.find(params[:id])
if @known_issue.update(known_issue_params)
flash[:notice] = "Known issue was successfully updated."
redirect_to(@known_issue)
else
render action: "edit"
end
end
# DELETE /known_issues/1
def destroy
@known_issue = authorize KnownIssue.find(params[:id])
@known_issue.destroy
redirect_to(known_issues_path)
end
private
def known_issue_params
params.require(:known_issue).permit(:title, :content)
end
end

View file

@ -0,0 +1,94 @@
class KudosController < ApplicationController
skip_before_action :store_location
before_action :load_parent, only: [:index]
before_action :check_parent_visible, only: [:index]
before_action :admin_logout_required, only: [:create]
def load_parent
@work = Work.find(params[:work_id])
end
def check_parent_visible
check_visibility_for(@work)
end
def index
@kudos = @work.kudos.includes(:user).with_user
@guest_kudos_count = @work.kudos.by_guest.count
respond_to do |format|
format.html do
@kudos = @kudos.order(id: :desc).paginate(
page: params[:page],
per_page: ArchiveConfig.MAX_KUDOS_TO_SHOW
)
end
format.js do
@kudos = @kudos.where("id < ?", params[:before].to_i) if params[:before]
end
end
end
def create
@kudo = Kudo.new(kudo_params)
if current_user.present?
@kudo.user = current_user
else
@kudo.ip_address = request.remote_ip
end
if @kudo.save
respond_to do |format|
format.html do
flash[:kudos_notice] = t(".success")
redirect_to request.referer and return
end
format.js do
@commentable = @kudo.commentable
@kudos = @commentable.kudos.with_user.includes(:user)
render :create, status: :created
end
end
else
error_message = @kudo.errors.full_messages.first
respond_to do |format|
format.html do
# If user is suspended or banned, JavaScript disabled, redirect user to dashboard with message instead.
return if check_user_status
flash[:kudos_error] = error_message
redirect_to request.referer and return
end
format.js do
render json: { error_message: error_message }, status: :unprocessable_entity
end
end
end
rescue ActiveRecord::RecordNotUnique
# Uniqueness checks at application level (Rails validations) are inherently
# prone to race conditions. If we pass Rails validations but get rejected
# by database unique indices, use the usual duplicate error message.
#
# https://api.rubyonrails.org/v5.1/classes/ActiveRecord/Validations/ClassMethods.html#method-i-validates_uniqueness_of-label-Concurrency+and+integrity
error_message = t("activerecord.errors.models.kudo.taken")
respond_to do |format|
format.html do
flash[:kudos_error] = error_message
redirect_to request.referer
end
format.js do
render json: { error_message: error_message }, status: :unprocessable_entity
end
end
end
private
def kudo_params
params.require(:kudo).permit(:commentable_id, :commentable_type)
end
end

View file

@ -0,0 +1,53 @@
class LanguagesController < ApplicationController
def index
@languages = Language.default_order
@works_counts = Rails.cache.fetch("/v1/languages/work_counts/#{current_user.present?}", expires_in: 1.day) do
WorkQuery.new.works_per_language(@languages.count)
end
end
def new
@language = Language.new
authorize @language
end
def create
@language = Language.new(language_params)
authorize @language
if @language.save
flash[:notice] = t("languages.successfully_added")
redirect_to languages_path
else
render action: "new"
end
end
def edit
@language = Language.find_by(short: params[:id])
authorize @language
return unless @language == Language.default
flash[:error] = t("languages.cannot_edit_default")
redirect_to languages_path
end
def update
@language = Language.find_by(short: params[:id])
authorize @language
if @language.update(permitted_attributes(@language))
flash[:notice] = t("languages.successfully_updated")
redirect_to languages_path
else
render action: "new"
end
end
private
def language_params
params.require(:language).permit(
:name, :short, :support_available, :abuse_support_available, :sortable_name
)
end
end

View file

@ -0,0 +1,55 @@
class LocalesController < ApplicationController
def index
authorize Locale
@locales = Locale.default_order
end
def new
@locale = Locale.new
authorize @locale
@languages = Language.default_order
end
# GET /locales/en/edit
def edit
@locale = Locale.find_by(iso: params[:id])
authorize @locale
@languages = Language.default_order
end
def update
@locale = Locale.find_by(iso: params[:id])
@locale.attributes = locale_params
authorize @locale
if @locale.save
flash[:notice] = ts('Your locale was successfully updated.')
redirect_to action: 'index', status: 303
else
@languages = Language.default_order
render action: "edit"
end
end
def create
@locale = Locale.new(locale_params)
authorize @locale
if @locale.save
flash[:notice] = t('successfully_added', default: 'Locale was successfully added.')
redirect_to locales_path
else
@languages = Language.default_order
render action: "new"
end
end
private
def locale_params
params.require(:locale).permit(
:name, :iso, :language_id, :email_enabled, :interface_enabled
)
end
end

View file

@ -0,0 +1,25 @@
class MediaController < ApplicationController
before_action :load_collection
skip_before_action :store_location, only: [:show]
def index
uncategorized = Media.uncategorized
@media = Media.canonical.by_name.where.not(name: [ArchiveConfig.MEDIA_UNCATEGORIZED_NAME, ArchiveConfig.MEDIA_NO_TAG_NAME]) + [uncategorized]
@fandom_listing = {}
@media.each do |medium|
if medium == uncategorized
@fandom_listing[medium] = medium.children.in_use.by_type('Fandom').order('created_at DESC').limit(5)
else
@fandom_listing[medium] = (logged_in? || logged_in_as_admin?) ?
# was losing the select trying to do this through the parents association
Fandom.unhidden_top(5).joins(:common_taggings).where(canonical: true, common_taggings: {filterable_id: medium.id, filterable_type: 'Tag'}) :
Fandom.public_top(5).joins(:common_taggings).where(canonical: true, common_taggings: {filterable_id: medium.id, filterable_type: 'Tag'})
end
end
@page_subtitle = t(".browser_title")
end
def show
redirect_to media_fandoms_path(media_id: params[:id])
end
end

View file

@ -0,0 +1,23 @@
class MenuController < ApplicationController
# about menu
def about
render action: "about", layout: "application"
end
# browse menu
def browse
render action: "browse", layout: "application"
end
# fandoms menu
def fandoms
render action: "fandoms", layout: "application"
end
# search menu
def search
render action: "search", layout: "application"
end
end

View file

@ -0,0 +1,89 @@
module Muted
class UsersController < ApplicationController
before_action :set_user
before_action :check_ownership, except: :index
before_action :check_ownership_or_admin, only: :index
before_action :check_admin_permissions, only: :index
before_action :build_mute, only: [:confirm_mute, :create]
before_action :set_mute, only: [:confirm_unmute, :destroy]
# GET /users/:user_id/muted/users
def index
@mutes = @user.mutes_as_muter
.joins(:muted)
.includes(muted: [:pseuds, { default_pseud: { icon_attachment: { blob: {
variant_records: { image_attachment: :blob },
preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } }
} } } }])
.order(created_at: :desc).order(id: :desc).page(params[:page])
@pseuds = @mutes.map { |b| b.muted.default_pseud }
@rec_counts = Pseud.rec_counts_for_pseuds(@pseuds)
@work_counts = Pseud.work_counts_for_pseuds(@pseuds)
@page_subtitle = t(".title")
end
# GET /users/:user_id/muted/users/confirm_mute
def confirm_mute
@hide_dashboard = true
return if @mute.valid?
# We can't mute this user for whatever reason
flash[:error] = @mute.errors.full_messages.first
redirect_to user_muted_users_path(@user)
end
# GET /users/:user_id/muted/users/confirm_unmute
def confirm_unmute
@hide_dashboard = true
@muted = @mute.muted
end
# POST /users/:user_id/muted/users
def create
if @mute.save
flash[:notice] = t(".muted", name: @mute.muted.login)
else
# We can't mute this user for whatever reason
flash[:error] = @mute.errors.full_messages.first
end
redirect_to user_muted_users_path(@user)
end
# DELETE /users/:user_id/muted/users/:id
def destroy
@mute.destroy
flash[:notice] = t(".unmuted", name: @mute.muted.login)
redirect_to user_muted_users_path(@user)
end
private
# Sets the user whose mutes we're viewing/modifying.
def set_user
@user = User.find_by!(login: params[:user_id])
@check_ownership_of = @user
end
# Builds (but doesn't save) a mute matching the desired params:
def build_mute
muted_byline = params.fetch(:muted_id, "")
@mute = @user.mutes_as_muter.build(muted_byline: muted_byline)
@muted = @mute.muted
end
def set_mute
@mute = @user.mutes_as_muter.find(params[:id])
end
def check_admin_permissions
authorize Mute if logged_in_as_admin?
end
end
end

View file

@ -0,0 +1,78 @@
class Opendoors::ExternalAuthorsController < ApplicationController
before_action :users_only
before_action :opendoors_only
before_action :load_external_author, only: [:show, :forward]
def load_external_author
@external_author = ExternalAuthor.find(params[:id])
end
def index
if params[:query]
@query = params[:query]
sql_query = '%' + @query +'%'
@external_authors = ExternalAuthor.where("external_authors.email LIKE ?", sql_query)
else
@external_authors = ExternalAuthor.unclaimed
end
# list in reverse order
@external_authors = @external_authors.order("created_at DESC").paginate(page: params[:page])
end
def show
end
def new
@external_author = ExternalAuthor.new
end
# create an external author identity (and pre-emptively block it)
def create
@external_author = ExternalAuthor.new(external_author_params)
unless @external_author.save
flash[:error] = ts("We couldn't save that address.")
else
flash[:notice] = ts("We have saved and blocked the email address %{email}", email: @external_author.email)
end
redirect_to opendoors_tools_path
end
def forward
if @external_author.is_claimed
flash[:error] = ts("This external author has already been claimed!")
redirect_to opendoors_external_author_path(@external_author) and return
end
# get the invitation
@invitation = Invitation.where(external_author_id: @external_author.id).first
unless @invitation
# if there is no invite we create one
@invitation = Invitation.new(external_author: @external_author)
end
# send the invitation to specified address
@email = params[:email]
@invitation.invitee_email = @email
@invitation.creator = User.find_by(login: "open_doors") || current_user
if @invitation.save
flash[:notice] = ts("Claim invitation for %{author_email} has been forwarded to %{invitee_email}!", author_email: @external_author.email, invitee_email: @invitation.invitee_email)
else
flash[:error] = ts("We couldn't forward the claim for %{author_email} to that email address.", author_email: @external_author.email) + @invitation.errors.full_messages.join(", ")
end
# redirect to external author listing for that user
redirect_to opendoors_external_authors_path(query: @external_author.email)
end
private
def external_author_params
params.require(:external_author).permit(
:email, :do_not_email, :do_not_import
)
end
end

View file

@ -0,0 +1,61 @@
class Opendoors::ToolsController < ApplicationController
before_action :users_only
before_action :opendoors_only
def index
@imported_from_url = params[:imported_from_url]
@external_author = ExternalAuthor.new
end
# Update the imported_from_url value on an existing AO3 work
# This is not RESTful but is IMO a better idea than setting up a works controller under the opendoors namespace,
# since the functionality we want to provide is so limited.
def url_update
# extract the work id and find the work
if params[:work_url] && params[:work_url].match(/works\/([0-9]+)\/?$/)
work_id = $1
@work = Work.find_by_id(work_id)
end
unless @work
flash[:error] = ts("We couldn't find that work on the Archive. Have you put in the full URL?")
redirect_to action: :index and return
end
# check validity of the new redirecting url
unless params[:imported_from_url].blank?
# try to parse the original entered url
begin
URI.parse(params[:imported_from_url])
@imported_from_url = params[:imported_from_url]
rescue
end
# if that didn't work, try to encode the URL and then parse it
if @imported_from_url.blank?
begin
URI.parse(URI::Parser.new.escape(params[:imported_from_url]))
@imported_from_url = URI::Parser.new.escape(params[:imported_from_url])
rescue
end
end
end
if @imported_from_url.blank?
flash[:error] = ts("The imported-from url you are trying to set doesn't seem valid.")
else
# check for any other works
works = Work.where(imported_from_url: @imported_from_url)
if works.count > 0
flash[:error] = ts("There is already a work imported from the url %{url}.", url: @imported_from_url)
else
# ok let's try to update
@work.update_attribute(:imported_from_url, @imported_from_url)
flash[:notice] = "Updated imported-from url for #{@work.title} to #{@imported_from_url}"
end
end
redirect_to action: :index, imported_from_url: @imported_from_url and return
end
end

View file

@ -0,0 +1,90 @@
class OrphansController < ApplicationController
# You must be logged in to orphan works - relies on current_user data
before_action :users_only, except: [:index]
before_action :check_user_not_suspended, except: [:index]
before_action :load_pseuds, only: [:create]
before_action :load_orphans, only: [:create]
def index
@user = User.orphan_account
@works = @user.works
end
def new
if params[:work_id]
@to_be_orphaned = Work.find(params[:work_id])
check_one_owned(@to_be_orphaned, current_user.works)
elsif params[:work_ids]
@to_be_orphaned = Work.where(id: params[:work_ids]).to_a
check_all_owned(@to_be_orphaned, current_user.works)
elsif params[:series_id]
@to_be_orphaned = Series.find(params[:series_id])
check_one_owned(@to_be_orphaned, current_user.series)
elsif params[:pseud_id]
@to_be_orphaned = Pseud.find(params[:pseud_id])
check_one_owned(@to_be_orphaned, current_user.pseuds)
else
@to_be_orphaned = current_user
end
end
def create
use_default = params[:use_default] == "true"
Creatorship.orphan(@pseuds, @orphans, use_default)
flash[:notice] = ts("Orphaning was successful.")
redirect_to user_path(current_user)
end
protected
def show_orphan_permission_error
flash[:error] = ts("You don't have permission to orphan that!")
redirect_to root_path
end
# Given an ActiveRecord item and an ActiveRecord relation, check whether the
# item is in the relation. If not, show a flash error.
def check_one_owned(chosen_item, all_owned_items)
show_orphan_permission_error unless all_owned_items.exists?(chosen_item.id)
end
# Given a collection of ActiveRecords and an ActiveRecord relation, check
# whether all items in the collection are contained in the relation. If not,
# show a flash error.
def check_all_owned(chosen_items, all_owned_items)
chosen_ids = chosen_items.map(&:id)
owned_ids = all_owned_items.where(id: chosen_ids).pluck(:id)
unowned_ids = chosen_ids - owned_ids
show_orphan_permission_error if unowned_ids.any?
end
# Load the list of works or series into the @orphans variable, and verify
# that the current user owns the works/series in question.
def load_orphans
if params[:work_ids]
@orphans = Work.where(id: params[:work_ids]).to_a
check_all_owned(@orphans, current_user.works)
elsif params[:series_id]
@orphans = Series.where(id: params[:series_id]).to_a
check_all_owned(@orphans, current_user.series)
else
flash[:error] = ts("What did you want to orphan?")
redirect_to current_user
end
end
# If a pseud_id is specified, load it and check that it belongs to the
# current user. Otherwise, assume that the user wants to orphan with all of
# their pseuds.
def load_pseuds
if params[:pseud_id]
@pseuds = Pseud.where(id: params[:pseud_id]).to_a
check_all_owned(@pseuds, current_user.pseuds)
else
@pseuds = current_user.pseuds
# We don't need to check ownership here because these pseuds are
# guaranteed to be owned by the current user.
end
end
end

View file

@ -0,0 +1,239 @@
class OwnedTagSetsController < ApplicationController
cache_sweeper :tag_set_sweeper
before_action :load_tag_set, except: [:index, :new, :create, :show_options]
before_action :users_only, only: [:new, :create]
before_action :moderators_only, except: [:index, :new, :create, :show, :show_options]
before_action :owners_only, only: [:destroy]
def load_tag_set
@tag_set = OwnedTagSet.find_by(id: params[:id])
unless @tag_set
flash[:error] = ts("What Tag Set did you want to look at?")
redirect_to tag_sets_path and return
end
end
def moderators_only
@tag_set.user_is_moderator?(current_user) || access_denied
end
def owners_only
@tag_set.user_is_owner?(current_user) || access_denied
end
def nominated_only
@tag_set.nominated || access_denied
end
### ACTIONS
def index
if params[:user_id]
@user = User.find_by login: params[:user_id]
@tag_sets = OwnedTagSet.owned_by(@user)
elsif params[:restriction]
@restriction = PromptRestriction.find(params[:restriction])
@tag_sets = OwnedTagSet.in_prompt_restriction(@restriction)
if @tag_sets.count == 1
redirect_to tag_set_path(@tag_sets.first, tag_type: (params[:tag_type] || "fandom")) and return
end
else
@tag_sets = OwnedTagSet
if params[:query]
@query = params[:query]
@tag_sets = @tag_sets.where("title LIKE ?", '%' + params[:query] + '%')
else
# show a random selection
@tag_sets = @tag_sets.order("created_at DESC")
end
end
@tag_sets = @tag_sets.paginate(per_page: (params[:per_page] || ArchiveConfig.ITEMS_PER_PAGE), page: (params[:page] || 1))
end
def show_options
@restriction = PromptRestriction.find_by(id: params[:restriction])
unless @restriction
flash[:error] = ts("Which Tag Set did you want to look at?")
redirect_to tag_sets_path and return
end
@tag_sets = OwnedTagSet.in_prompt_restriction(@restriction)
@tag_set_ids = @tag_sets.pluck(:tag_set_id)
@tag_type = params[:tag_type] && TagSet::TAG_TYPES.include?(params[:tag_type]) ? params[:tag_type] : "fandom"
# @tag_type is restricted by in_prompt_restriction and therefore safe to pass to constantize
@tags = @tag_type.classify.constantize.joins(:set_taggings).where("set_taggings.tag_set_id IN (?)", @tag_set_ids).by_name_without_articles
end
def show
# don't bother collecting tags unless the user gets to see them
if @tag_set.visible || @tag_set.user_is_moderator?(current_user)
# we use this to collect up fandom parents for characters and relationships
@fandom_keys_from_other_tags = []
# we use this to store the tag name results
@tag_hash = HashWithIndifferentAccess.new
%w[character relationship].each do |tag_type|
next unless @tag_set.has_type?(tag_type)
## names_by_parent returns a hash of arrays like so:
## hash[parent_name] => [child name, child name, child name]
# get the manually associated fandoms
assoc_hash = TagSetAssociation.names_by_parent(TagSetAssociation.for_tag_set(@tag_set), tag_type)
# get canonically associated fandoms
# Safe for constantize as tag_type restricted to character relationship
canonical_hash = Tag.names_by_parent(tag_type.classify.constantize.in_tag_set(@tag_set).canonical, "fandom")
# merge the values of the two hashes (each value is an array) as a set (ie remove duplicates)
@tag_hash[tag_type] = assoc_hash.merge(canonical_hash) { |_key, oldval, newval| (oldval | newval) }
# get any tags without a fandom
remaining = @tag_set.with_type(tag_type).where.not(name: @tag_hash[tag_type].values.flatten)
if remaining.any?
@tag_hash[tag_type]["(No linked fandom - might need association)"] ||= []
@tag_hash[tag_type]["(No linked fandom - might need association)"] += remaining.pluck(:name)
end
# store the parents
@fandom_keys_from_other_tags += @tag_hash[tag_type].keys
end
# get rid of duplicates and sort
@fandom_keys_from_other_tags = @fandom_keys_from_other_tags.compact.uniq.sort {|a,b| a.gsub(/^(the |an |a )/, '') <=> b.gsub(/^(the |an |a )/, '')}
# now handle fandoms
if @tag_set.has_type?("fandom")
# Get fandoms hashed by media -- just the canonical associations
@tag_hash[:fandom] = Tag.names_by_parent(Fandom.in_tag_set(@tag_set), "media")
# get any fandoms without a media
if @tag_set.with_type("fandom").with_no_parents.count > 0
@tag_hash[:fandom]["(No Media)"] ||= []
@tag_hash[:fandom]["(No Media)"] += @tag_set.with_type("fandom").with_no_parents.pluck(:name)
end
# we want to collect and warn about any chars or relationships not in the set's fandoms
@character_seen = {}
@relationship_seen = {}
# clear out the fandoms we're showing from the list of other tags
if @fandom_keys_from_other_tags && !@fandom_keys_from_other_tags.empty?
@fandom_keys_from_other_tags -= @tag_hash[:fandom].values.flatten
end
@unassociated_chars = []
@unassociated_rels = []
unless @fandom_keys_from_other_tags.empty?
if @tag_hash[:character]
@unassociated_chars = @tag_hash[:character].values_at(*@fandom_keys_from_other_tags).flatten.compact.uniq
end
if @tag_hash[:relationship]
@unassociated_rels = @tag_hash[:relationship].values_at(*@fandom_keys_from_other_tags).flatten.compact.uniq
end
end
end
end
end
def new
@tag_set = OwnedTagSet.new
end
def create
@tag_set = OwnedTagSet.new(owned_tag_set_params)
@tag_set.add_owner(current_user.default_pseud)
if @tag_set.save
flash[:notice] = ts('Tag Set was successfully created.')
redirect_to tag_set_path(@tag_set)
else
render action: "new"
end
end
def edit
get_parent_child_tags
end
def update
if @tag_set.update(owned_tag_set_params) && @tag_set.tag_set.save!
flash[:notice] = ts("Tag Set was successfully updated.")
redirect_to tag_set_path(@tag_set)
else
get_parent_child_tags
render action: :edit
end
end
def confirm_delete
end
def destroy
@tag_set = OwnedTagSet.find(params[:id])
begin
name = @tag_set.title
@tag_set.destroy
flash[:notice] = ts("Your Tag Set %{name} was deleted.", name: name)
rescue
flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
end
redirect_to tag_sets_path
end
def batch_load
end
def do_batch_load
if params[:batch_associations]
failed = @tag_set.load_batch_associations!(params[:batch_associations], do_relationships: (params[:batch_do_relationships] ? true : false))
if failed.empty?
flash[:notice] = ts("Tags and associations loaded!")
redirect_to tag_set_path(@tag_set) and return
else
flash.now[:notice] = ts("We couldn't add all the tags and associations you wanted -- the ones left below didn't work. See the help for suggestions!")
@failed_batch_associations = failed.join("\n")
render action: :batch_load and return
end
else
flash[:error] = ts("What did you want to load?")
redirect_to action: :batch_load and return
end
end
protected
# for manual associations
def get_parent_child_tags
@tags_in_set = Tag.joins(:set_taggings).where("set_taggings.tag_set_id = ?", @tag_set.tag_set_id).order("tags.name ASC")
@parent_tags_in_set = @tags_in_set.where(type: 'Fandom').pluck :name, :id
@child_tags_in_set = @tags_in_set.where("type IN ('Relationship', 'Character')").pluck :name, :id
end
private
def owned_tag_set_params
params.require(:owned_tag_set).permit(
:owner_changes, :moderator_changes, :title, :description, :visible,
:usable, :nominated, :fandom_nomination_limit, :character_nomination_limit,
:relationship_nomination_limit, :freeform_nomination_limit,
associations_to_remove: [],
tag_set_associations_attributes: [
:id, :create_association, :tag_id, :parent_tag_id, :_destroy
],
tag_set_attributes: [
:id,
:from_owned_tag_set, :fandom_tagnames_to_add, :character_tagnames_to_add,
:relationship_tagnames_to_add, :freeform_tagnames_to_add,
character_tagnames: [], rating_tagnames: [], archive_warning_tagnames: [],
category_tagnames: [], fandom_tags_to_remove: [], character_tags_to_remove: [],
relationship_tags_to_remove: [], freeform_tags_to_remove: []
]
)
end
end

View file

@ -0,0 +1,33 @@
class PeopleController < ApplicationController
before_action :load_collection
def search
if people_search_params.blank?
@search = PseudSearchForm.new({})
else
options = people_search_params.merge(page: params[:page])
@search = PseudSearchForm.new(options)
@people = @search.search_results.scope(:for_search)
flash_search_warnings(@people)
end
end
def index
if @collection.present?
@people = @collection.participants.with_attached_icon.includes(:user).order(:name).page(params[:page])
@rec_counts = Pseud.rec_counts_for_pseuds(@people)
@work_counts = Pseud.work_counts_for_pseuds(@people)
@page_subtitle = t(".collection_page_title", collection_title: @collection.title)
else
redirect_to search_people_path
end
end
protected
def people_search_params
return {} unless params[:people_search].present?
params[:people_search].permit!
end
end

View file

@ -0,0 +1,146 @@
class PotentialMatchesController < ApplicationController
before_action :users_only
before_action :load_collection
before_action :collection_maintainers_only
before_action :load_challenge
before_action :check_assignments_not_sent
before_action :check_signup_closed, only: [:generate]
before_action :load_potential_match_from_id, only: [:show]
def load_challenge
@challenge = @collection.challenge
no_challenge and return unless @challenge
end
def no_challenge
flash[:error] = ts("What challenge did you want to sign up for?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def load_potential_match_from_id
@potential_match = PotentialMatch.find(params[:id])
no_potential_match and return unless @potential_match
end
def no_assignment
flash[:error] = ts("What potential match did you want to work on?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def check_signup_closed
signup_open and return unless !@challenge.signup_open
end
def signup_open
flash[:error] = ts("Sign-up is still open, you cannot determine potential matches now.")
redirect_to @collection rescue redirect_to '/'
false
end
def check_assignments_not_sent
assignments_sent and return unless @challenge.assignments_sent_at.nil?
end
def assignments_sent
flash[:error] = ts("Assignments have already been sent! If necessary, you can purge them.")
redirect_to collection_assignments_path(@collection) rescue redirect_to '/'
false
end
def index
@settings = @collection.challenge.potential_match_settings
if (invalid_ids = PotentialMatch.invalid_signups_for(@collection)).present?
# there are invalid signups
@invalid_signups = ChallengeSignup.where(id: invalid_ids)
elsif PotentialMatch.in_progress?(@collection)
# we're generating
@in_progress = true
@progress = PotentialMatch.progress(@collection)
elsif ChallengeAssignment.in_progress?(@collection)
@assignment_in_progress = true
elsif @collection.potential_matches.count > 0 && @collection.assignments.count == 0
flash[:error] = ts("There has been an error in the potential matching. Please first try regenerating assignments, and if that doesn't work, all potential matches. If the problem persists, please contact Support.")
elsif @collection.potential_matches.count > 0
# we have potential_matches and assignments
### find assignments with no potential recipients
# first get signups with no offer potential matches
no_opms = ChallengeSignup.in_collection(@collection).no_potential_offers.pluck(:id)
@assignments_with_no_potential_recipients = @collection.assignments.where(offer_signup_id: no_opms)
### find assignments with no potential giver
# first get signups with no request potential matches
no_rpms = ChallengeSignup.in_collection(@collection).no_potential_requests.pluck(:id)
@assignments_with_no_potential_givers = @collection.assignments.where(request_signup_id: no_rpms)
# list the assignments by requester
if params[:no_giver]
@assignments = @collection.assignments.with_request.with_no_offer.order_by_requesting_pseud
elsif params[:no_recipient]
# ordering causes this to hang on large challenge due to
# left join required to get offering pseuds
@assignments = @collection.assignments.with_offer.with_no_request # .order_by_offering_pseud
elsif params[:dup_giver]
@assignments = ChallengeAssignment.duplicate_givers(@collection).order_by_offering_pseud
elsif params[:dup_recipient]
@assignments = ChallengeAssignment.duplicate_recipients(@collection).order_by_requesting_pseud
else
@assignments = @collection.assignments.with_request.with_offer.order_by_requesting_pseud
end
@assignments = @assignments.paginate page: params[:page], per_page: ArchiveConfig.ITEMS_PER_PAGE
end
end
# Generate potential matches
def generate
if PotentialMatch.in_progress?(@collection)
flash[:error] = ts("Potential matches are already being generated for this collection!")
else
# delete all existing assignments and potential matches for this collection
ChallengeAssignment.clear!(@collection)
PotentialMatch.clear!(@collection)
flash[:notice] = ts("Beginning generation of potential matches. This may take some time, especially if your challenge is large.")
PotentialMatch.set_up_generating(@collection)
PotentialMatch.generate(@collection)
end
# redirect to index
redirect_to collection_potential_matches_path(@collection)
end
# Regenerate matches for one signup
def regenerate_for_signup
if params[:signup_id].blank? || (@signup = ChallengeSignup.where(id: params[:signup_id]).first).nil?
flash[:error] = ts("What sign-up did you want to regenerate matches for?")
else
PotentialMatch.regenerate_for_signup(@signup)
flash[:notice] = ts("Matches are being regenerated for ") + @signup.pseud.byline +
ts(". Please allow at least 5 minutes for this process to complete before refreshing the page.")
end
# redirect to index
redirect_to collection_potential_matches_path(@collection)
end
def cancel_generate
if !PotentialMatch.in_progress?(@collection)
flash[:error] = ts("Potential matches are not currently being generated for this challenge.")
elsif PotentialMatch.canceled?(@collection)
flash[:error] = ts("Potential match generation has already been canceled, please refresh again shortly.")
else
PotentialMatch.cancel_generation(@collection)
flash[:notice] = ts("Potential match generation cancellation requested. This may take a while, please refresh shortly.")
end
redirect_to collection_potential_matches_path(@collection)
end
def show
end
end

View file

@ -0,0 +1,72 @@
class PreferencesController < ApplicationController
before_action :load_user
before_action :check_ownership
skip_before_action :store_location
# Ensure that the current user is authorized to view and change this information
def load_user
@user = User.find_by(login: params[:user_id])
@check_ownership_of = @user
end
def index
@user = User.find_by(login: params[:user_id])
@preference = @user.preference
@available_skins = (current_user.skins.site_skins + Skin.approved_skins.site_skins).uniq
@available_locales = Locale.where(email_enabled: true)
end
def update
@user = User.find_by(login: params[:user_id])
@preference = @user.preference
@user.preference.attributes = preference_params
@available_skins = (current_user.skins.site_skins + Skin.approved_skins.site_skins).uniq
@available_locales = Locale.where(email_enabled: true)
if params[:preference][:skin_id].present?
# unset session skin if user changed their skin
session[:site_skin] = nil
end
if @user.preference.save
flash[:notice] = ts('Your preferences were successfully updated.')
redirect_to user_path(@user)
else
flash[:error] = ts('Sorry, something went wrong. Please try that again.')
render action: :index
end
end
private
def preference_params
params.require(:preference).permit(
:minimize_search_engines,
:disable_share_links,
:adult,
:view_full_works,
:hide_warnings,
:hide_freeform,
:disable_work_skins,
:skin_id,
:time_zone,
:preferred_locale,
:work_title_format,
:comment_emails_off,
:comment_inbox_off,
:comment_copy_to_self_off,
:kudos_emails_off,
:admin_emails_off,
:allow_collection_invitation,
:collection_emails_off,
:collection_inbox_off,
:recipient_emails_off,
:history_enabled,
:first_login,
:banner_seen,
:allow_cocreator,
:allow_gifts,
:guest_replies_off
)
end
end

View file

@ -0,0 +1,45 @@
class ProfileController < ApplicationController
before_action :load_user_and_pseuds
def show
@user = User.find_by(login: params[:user_id])
if @user.profile.nil?
Profile.create(user_id: @user.id)
@user.reload
end
@profile = @user.profile
# code the same as the stuff in users_controller
if current_user.respond_to?(:subscriptions)
@subscription = current_user.subscriptions.where(subscribable_id: @user.id,
subscribable_type: "User").first ||
current_user.subscriptions.build(subscribable: @user)
end
@page_subtitle = t(".page_title", username: @user.login)
end
def pseuds
respond_to do |format|
format.html do
redirect_to user_pseuds_path(@user)
end
format.js
end
end
private
def load_user_and_pseuds
@user = User.find_by(login: params[:user_id])
if @user.nil?
flash[:error] = ts("Sorry, there's no user by that name.")
redirect_to root_path
return
end
@pseuds = @user.pseuds.default_alphabetical.paginate(page: params[:page])
end
end

View file

@ -0,0 +1,211 @@
class PromptsController < ApplicationController
before_action :users_only, except: [:show]
before_action :load_collection, except: [:index]
before_action :load_challenge, except: [:index]
before_action :load_prompt_from_id, only: [:show, :edit, :update, :destroy]
before_action :load_signup, except: [:index, :destroy, :show]
# before_action :promptmeme_only, except: [:index, :new]
before_action :allowed_to_destroy, only: [:destroy]
before_action :allowed_to_view, only: [:show]
before_action :signup_owner_only, only: [:edit, :update]
before_action :check_signup_open, only: [:new, :create, :edit, :update]
before_action :check_prompt_in_collection, only: [:show, :edit, :update, :destroy]
# def promptmeme_only
# unless @collection.challenge_type == "PromptMeme"
# flash[:error] = ts("Only available for prompt meme challenges, not gift exchanges")
# redirect_to collection_path(@collection) rescue redirect_to '/'
# end
# end
def load_challenge
@challenge = @collection.challenge
no_challenge and return unless @challenge
end
def no_challenge
flash[:error] = ts("What challenge did you want to sign up for?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def load_signup
unless @challenge_signup
@challenge_signup = ChallengeSignup.in_collection(@collection).by_user(current_user).first
end
no_signup and return unless @challenge_signup
end
def no_signup
flash[:error] = ts("Please submit a basic sign-up with the required fields first.")
redirect_to new_collection_signup_path(@collection) rescue redirect_to '/'
false
end
def check_signup_open
signup_closed and return unless (@challenge.signup_open || @collection.user_is_owner?(current_user) || @collection.user_is_moderator?(current_user))
end
def signup_closed
flash[:error] = ts("Signup is currently closed: please contact a moderator for help.")
redirect_to @collection rescue redirect_to '/'
false
end
def signup_owner_only
not_signup_owner and return unless (@challenge_signup.pseud.user == current_user || (@collection.challenge_type == "GiftExchange" && !@challenge.signup_open && @collection.user_is_owner?(current_user)))
end
def maintainer_or_signup_owner_only
not_allowed(@collection) and return unless (@challenge_signup.pseud.user == current_user || @collection.user_is_maintainer?(current_user))
end
def not_signup_owner
flash[:error] = ts("You can't edit someone else's sign-up!")
redirect_to @collection
false
end
def allowed_to_destroy
@challenge_signup.user_allowed_to_destroy?(current_user) || not_allowed(@collection)
end
def load_prompt_from_id
@prompt = Prompt.find_by(id: params[:id])
if @prompt.nil?
no_prompt
return
end
@challenge_signup = @prompt.challenge_signup
end
def no_prompt
flash[:error] = ts("What prompt did you want to work on?")
redirect_to collection_path(@collection) rescue redirect_to '/'
false
end
def check_prompt_in_collection
unless @prompt.collection_id == @collection.id
flash[:error] = ts("Sorry, that prompt isn't associated with that collection.")
redirect_to @collection
end
end
def allowed_to_view
unless @challenge.user_allowed_to_see_prompt?(current_user, @prompt)
access_denied(redirect: @collection)
end
end
#### ACTIONS
def index
# this currently doesn't get called anywhere
# should probably list all the prompts in a given collection (instead of using challenge signup for that)
end
def show
end
def new
if params[:prompt_type] == "offer"
@index = @challenge_signup.offers.count
@prompt = @challenge_signup.offers.build
else
@index = @challenge_signup.requests.count
@prompt = @challenge_signup.requests.build
end
end
def edit
@index = @challenge_signup.send(@prompt.class.name.downcase.pluralize).index(@prompt)
end
def create
if params[:prompt_type] == "offer"
@prompt = @challenge_signup.offers.build(prompt_params)
else
@prompt = @challenge_signup.requests.build(prompt_params)
end
if @challenge_signup.save
flash[:notice] = ts("Prompt was successfully added.")
redirect_to collection_signup_path(@collection, @challenge_signup)
else
flash[:error] = ts("That prompt would make your overall sign-up invalid, sorry.")
redirect_to edit_collection_signup_path(@collection, @challenge_signup)
end
end
def update
if @prompt.update(prompt_params)
flash[:notice] = ts("Prompt was successfully updated.")
redirect_to collection_signup_path(@collection, @challenge_signup)
else
render action: :edit
end
end
def destroy
if !(@challenge.signup_open || @collection.user_is_maintainer?(current_user))
flash[:error] = ts("You cannot delete a prompt after sign-ups are closed. Please contact a moderator for help.")
else
if !@prompt.can_delete?
flash[:error] = ts("That would make your sign-up invalid, sorry! Please edit instead.")
else
@prompt.destroy
flash[:notice] = ts("Prompt was deleted.")
end
end
if @collection.user_is_maintainer?(current_user) && @collection.challenge_type == "PromptMeme"
redirect_to collection_requests_path(@collection)
elsif @prompt.challenge_signup
redirect_to collection_signup_path(@collection, @prompt.challenge_signup)
elsif @collection.user_is_maintainer?(current_user)
redirect_to collection_signups_path(@collection)
else
redirect_to @collection
end
end
private
def prompt_params
params.require(:prompt).permit(
:title,
:url,
:anonymous,
:description,
:any_fandom,
:any_character,
:any_relationship,
:any_freeform,
:any_category,
:any_rating,
:any_archive_warning,
tag_set_attributes: [
:fandom_tagnames,
:id,
:updated_at,
:character_tagnames,
:relationship_tagnames,
:freeform_tagnames,
:category_tagnames,
:rating_tagnames,
:archive_warning_tagnames,
fandom_tagnames: [],
character_tagnames: [],
relationship_tagnames: [],
freeform_tagnames: [],
category_tagnames: [],
rating_tagnames: [],
archive_warning_tagnames: []
],
optional_tag_set_attributes: [
:tagnames
]
)
end
end

View file

@ -0,0 +1,143 @@
class PseudsController < ApplicationController
cache_sweeper :pseud_sweeper
before_action :load_user
before_action :check_ownership, only: [:create, :destroy, :new]
before_action :check_ownership_or_admin, only: [:edit, :update]
before_action :check_user_status, only: [:new, :create, :edit, :update]
def load_user
@user = User.find_by!(login: params[:user_id])
@check_ownership_of = @user
end
# GET /pseuds
# GET /pseuds.xml
def index
@pseuds = @user.pseuds.with_attached_icon.alphabetical.paginate(page: params[:page])
@rec_counts = Pseud.rec_counts_for_pseuds(@pseuds)
@work_counts = Pseud.work_counts_for_pseuds(@pseuds)
@page_subtitle = @user.login
end
# GET /users/:user_id/pseuds/:id
def show
@pseud = @user.pseuds.find_by!(name: params[:id])
@page_subtitle = @pseud.name
# very similar to show under users - if you change something here, change it there too
if logged_in? || logged_in_as_admin?
visible_works = @pseud.works.visible_to_registered_user
visible_series = @pseud.series.visible_to_registered_user
visible_bookmarks = @pseud.bookmarks.visible_to_registered_user
else
visible_works = @pseud.works.visible_to_all
visible_series = @pseud.series.visible_to_all
visible_bookmarks = @pseud.bookmarks.visible_to_all
end
visible_works = visible_works.revealed.non_anon
visible_series = visible_series.exclude_anonymous
@fandoms = \
Fandom.select("tags.*, count(DISTINCT works.id) as work_count")
.joins(:filtered_works).group("tags.id").merge(visible_works)
.where(filter_taggings: { inherited: false })
.order("work_count DESC").load
@works = visible_works.order("revised_at DESC").limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
@series = visible_series.order("updated_at DESC").limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
@bookmarks = visible_bookmarks.order("updated_at DESC").limit(ArchiveConfig.NUMBER_OF_ITEMS_VISIBLE_IN_DASHBOARD)
return unless current_user.respond_to?(:subscriptions)
@subscription = current_user.subscriptions.where(subscribable_id: @user.id,
subscribable_type: "User").first ||
current_user.subscriptions.build(subscribable: @user)
end
# GET /pseuds/new
# GET /pseuds/new.xml
def new
@pseud = @user.pseuds.build
end
# GET /pseuds/1/edit
def edit
@pseud = @user.pseuds.find_by!(name: params[:id])
authorize @pseud if logged_in_as_admin?
end
# POST /pseuds
# POST /pseuds.xml
def create
@pseud = Pseud.new(permitted_attributes(Pseud))
if @user.pseuds.where(name: @pseud.name).blank?
@pseud.user_id = @user.id
old_default = @user.default_pseud
if @pseud.save
flash[:notice] = t(".successfully_created")
if @pseud.is_default
# if setting this one as default, unset the attribute of the current default pseud
old_default.update_attribute(:is_default, false)
end
redirect_to polymorphic_path([@user, @pseud])
else
render action: "new"
end
else
# user tried to add pseud they already have
flash[:error] = t(".already_have_pseud_with_name")
render action: "new"
end
end
# PUT /pseuds/1
# PUT /pseuds/1.xml
def update
@pseud = @user.pseuds.find_by(name: params[:id])
authorize @pseud if logged_in_as_admin?
default = @user.default_pseud
if @pseud.update(permitted_attributes(@pseud))
if logged_in_as_admin? && @pseud.ticket_url.present?
link = view_context.link_to("Ticket ##{@pseud.ticket_number}", @pseud.ticket_url)
summary = "#{link} for User ##{@pseud.user_id}"
AdminActivity.log_action(current_admin, @pseud, action: "edit pseud", summary: summary)
end
# if setting this one as default, unset the attribute of the current default pseud
default.update_attribute(:is_default, false) if @pseud.is_default && default != @pseud
flash[:notice] = t(".successfully_updated")
redirect_to([@user, @pseud])
else
render action: "edit"
end
end
# DELETE /pseuds/1
# DELETE /pseuds/1.xml
def destroy
@hide_dashboard = true
if params[:cancel_button]
flash[:notice] = t(".not_deleted")
redirect_to(user_pseuds_path(@user)) && return
end
@pseud = @user.pseuds.find_by(name: params[:id])
if @pseud.is_default
flash[:error] = t(".cannot_delete_default")
elsif @pseud.name == @user.login
flash[:error] = t(".cannot_delete_matching_username")
elsif params[:bookmarks_action] == "transfer_bookmarks"
@pseud.change_bookmarks_ownership
@pseud.replace_me_with_default
flash[:notice] = t(".successfully_deleted")
elsif params[:bookmarks_action] == "delete_bookmarks" || @pseud.bookmarks.empty?
@pseud.replace_me_with_default
flash[:notice] = t(".successfully_deleted")
else
render "delete_preview" and return
end
redirect_to(user_pseuds_path(@user))
end
end

View file

@ -0,0 +1,38 @@
class QuestionsController < ApplicationController
before_action :load_archive_faq, except: :update_positions
# GET /archive_faq/:archive_faq_id/questions/manage
def manage
authorize :archive_faq, :full_access?
@questions = @archive_faq.questions.order("position")
end
# fetch archive_faq these questions belong to from db
def load_archive_faq
@archive_faq = ArchiveFaq.find_by(slug: params[:archive_faq_id])
unless @archive_faq.present?
flash[:error] = t("questions.not_found")
redirect_to root_path and return
end
end
# Update the position number of questions within a archive_faq
def update_positions
authorize :archive_faq, :full_access?
if params[:questions]
@archive_faq = ArchiveFaq.find_by(slug: params[:archive_faq_id])
@archive_faq.reorder_list(params[:questions])
flash[:notice] = t(".success")
elsif params[:question]
params[:question].each_with_index do |id, position|
Question.update(id, position: position + 1)
(@questions ||= []) << Question.find(id)
end
flash[:notice] = t(".success")
end
respond_to do |format|
format.html { redirect_to(@archive_faq) and return }
format.js { render nothing: true }
end
end
end

View file

@ -0,0 +1,70 @@
class ReadingsController < ApplicationController
before_action :users_only
before_action :load_user
before_action :check_ownership
before_action :check_history_enabled
def load_user
@user = User.find_by(login: params[:user_id])
@check_ownership_of = @user
end
def index
@readings = @user.readings.visible
@page_subtitle = ts("History")
if params[:show] == 'to-read'
@readings = @readings.where(toread: true)
@page_subtitle = ts("Marked For Later")
end
@readings = @readings.order("last_viewed DESC")
@pagy, @readings = pagy(@readings)
end
def destroy
@reading = @user.readings.find(params[:id])
if @reading.destroy
success_message = ts('Work successfully deleted from your history.')
respond_to do |format|
format.html { redirect_to request.referer || user_readings_path(current_user, page: params[:page]), notice: success_message }
format.json { render json: { item_success_message: success_message }, status: :ok }
end
else
respond_to do |format|
format.html do
flash.keep
redirect_to request.referer || user_readings_path(current_user, page: params[:page]), flash: { error: @reading.errors.full_messages }
end
format.json { render json: { errors: @reading.errors.full_messages }, status: :unprocessable_entity }
end
end
end
def clear
success = true
@user.readings.each do |reading|
reading.destroy!
rescue ActiveRecord::RecordNotDestroyed
success = false
end
if success
flash[:notice] = t(".success")
else
flash[:error] = t(".error")
end
redirect_to user_readings_path(current_user)
end
protected
# checks if user has history enabled and redirects to preferences if not, so they can potentially change it
def check_history_enabled
unless current_user.preference.history_enabled?
flash[:notice] = ts("You have reading history disabled in your preferences. Change it below if you'd like us to keep track of it.")
redirect_to user_preferences_path(current_user)
end
end
end

View file

@ -0,0 +1,29 @@
class RedirectController < ApplicationController
def index
do_redirect
end
def do_redirect
url = params[:original_url]
if url.blank?
flash[:error] = ts("What url did you want to look up?")
else
@work = Work.find_by_url(url)
if @work
flash[:notice] = ts("You have been redirected here from %{url}. Please update the original link if possible!", url: url)
redirect_to work_path(@work) and return
else
flash[:error] = ts("We could not find a work imported from that url in the Archive of Our Own, sorry! Try another url?")
end
end
redirect_to redirect_path
end
def show
if params[:original_url].present?
redirect_to action: :do_redirect, original_url: params[:original_url] and return
end
end
end

View file

@ -0,0 +1,96 @@
class RelatedWorksController < ApplicationController
before_action :load_user, only: [:index]
before_action :users_only, except: [:index]
before_action :get_instance_variables, except: [:index]
def index
@page_subtitle = t(".page_title", login: @user.login)
@translations_of_user = @user.related_works.posted.where(translation: true)
@remixes_of_user = @user.related_works.posted.where(translation: false)
@translations_by_user = @user.parent_work_relationships.posted.where(translation: true)
@remixes_by_user = @user.parent_work_relationships.posted.where(translation: false)
return if @user == current_user
# Extra constraints on what we display if someone else is viewing @user's
# related works page:
@translations_of_user = @translations_of_user.merge(Work.revealed.non_anon)
@remixes_of_user = @remixes_of_user.merge(Work.revealed.non_anon)
@translations_by_user = @translations_by_user.merge(Work.revealed.non_anon)
@remixes_by_user = @remixes_by_user.merge(Work.revealed.non_anon)
end
# GET /related_works/1
# GET /related_works/1.xml
def show
end
def update
# updates are done by the owner of the parent, to aprove or remove links on the parent work.
unless @user
if current_user_owns?(@child)
flash[:error] = ts("Sorry, but you don't have permission to do that. Try removing the link from your own work.")
redirect_back_or_default(user_related_works_path(current_user))
return
else
flash[:error] = ts("Sorry, but you don't have permission to do that.")
redirect_back_or_default(root_path)
return
end
end
# the assumption here is that any update is a toggle from what was before
@related_work.reciprocal = !@related_work.reciprocal?
if @related_work.update_attribute(:reciprocal, @related_work.reciprocal)
notice = @related_work.reciprocal? ? ts("Link was successfully approved") :
ts("Link was successfully removed")
flash[:notice] = notice
redirect_to(@related_work.parent)
else
flash[:error] = ts('Sorry, something went wrong.')
redirect_to(@related_work)
end
end
def destroy
# destroys are done by the owner of the child, to remove links to the parent work which also removes the link back if it exists.
unless current_user_owns?(@child)
if @user
flash[:error] = ts("Sorry, but you don't have permission to do that. You can only approve or remove the link from your own work.")
redirect_back_or_default(user_related_works_path(current_user))
return
else
flash[:error] = ts("Sorry, but you don't have permission to do that.")
redirect_back_or_default(root_path)
return
end
end
@related_work.destroy
redirect_back(fallback_location: user_related_works_path(current_user))
end
private
def load_user
if params[:user_id].blank?
flash[:error] = ts("Whose related works were you looking for?")
redirect_back_or_default(search_people_path)
else
@user = User.find_by(login: params[:user_id])
if @user.blank?
flash[:error] = ts("Sorry, we couldn't find that user")
redirect_back_or_default(root_path)
end
end
end
def get_instance_variables
@related_work = RelatedWork.find(params[:id])
@child = @related_work.work
if @related_work.parent.is_a? (Work)
@owners = @related_work.parent.pseuds.map(&:user)
@user = current_user if @owners.include?(current_user)
end
end
end

View file

@ -0,0 +1,25 @@
# Controller for Serial Works
class SerialWorksController < ApplicationController
before_action :load_serial_work
before_action :check_ownership
def load_serial_work
@serial_work = SerialWork.find(params[:id])
@check_ownership_of = @serial_work.series
end
# DELETE /related_works/1
# Updated so if last work in series is deleted redirects to current user works listing instead of throwing 404
def destroy
last_work = (@serial_work.series.works.count <= 1)
@serial_work.destroy
if last_work
redirect_to current_user
else
redirect_to series_path(@serial_work.series)
end
end
end

View file

@ -0,0 +1,156 @@
class SeriesController < ApplicationController
before_action :check_user_status, only: [:new, :create, :edit, :update]
before_action :load_series, only: [ :show, :edit, :update, :manage, :destroy, :confirm_delete ]
before_action :check_ownership, only: [ :edit, :update, :manage, :destroy, :confirm_delete ]
before_action :check_visibility, only: [:show]
def load_series
@series = Series.find_by(id: params[:id])
unless @series
raise ActiveRecord::RecordNotFound, "Couldn't find series '#{params[:id]}'"
end
@check_ownership_of = @series
@check_visibility_of = @series
end
# GET /series
# GET /series.xml
def index
unless params[:user_id]
flash[:error] = ts("Whose series did you want to see?")
redirect_to(root_path) and return
end
@user = User.find_by!(login: params[:user_id])
@page_subtitle = t(".page_title", username: @user.login)
@series = if current_user.nil?
Series.visible_to_all
else
Series.visible_to_registered_user
end
if params[:pseud_id]
@pseud = @user.pseuds.find_by!(name: params[:pseud_id])
@page_subtitle = t(".page_title", username: @pseud.name)
@series = @series.exclude_anonymous.for_pseud(@pseud)
else
@series = @series.exclude_anonymous.for_user(@user)
end
@series = @series.paginate(page: params[:page])
end
# GET /series/1
# GET /series/1.xml
def show
@works = @series.works_in_order.posted.select(&:visible?).paginate(page: params[:page])
# sets the page title with the data for the series
if @series.unrevealed?
@page_subtitle = t(".unrevealed_series")
else
@page_title = get_page_title(@series.allfandoms.collect(&:name).join(", "), @series.anonymous? ? t(".anonymous") : @series.allpseuds.collect(&:byline).join(", "), @series.title)
end
if current_user.respond_to?(:subscriptions)
@subscription = current_user.subscriptions.where(subscribable_id: @series.id,
subscribable_type: 'Series').first ||
current_user.subscriptions.build(subscribable: @series)
end
end
# GET /series/new
# GET /series/new.xml
def new
@series = Series.new
end
# GET /series/1/edit
def edit
if params["remove"] == "me"
pseuds_with_author_removed = @series.pseuds - current_user.pseuds
if pseuds_with_author_removed.empty?
redirect_to controller: 'orphans', action: 'new', series_id: @series.id
else
begin
@series.remove_author(current_user)
flash[:notice] = ts("You have been removed as a creator from the series and its works.")
redirect_to @series
rescue Exception => error
flash[:error] = error.message
redirect_to @series
end
end
end
end
# GET /series/1/manage
def manage
@serial_works = @series.serial_works.includes(:work).order(:position)
end
# POST /series
# POST /series.xml
def create
@series = Series.new(series_params)
if @series.save
flash[:notice] = ts('Series was successfully created.')
redirect_to(@series)
else
render action: "new"
end
end
# PUT /series/1
# PUT /series/1.xml
def update
@series.attributes = series_params
if @series.errors.empty? && @series.save
flash[:notice] = ts('Series was successfully updated.')
redirect_to(@series)
else
render action: "edit"
end
end
def update_positions
if params[:serial_works]
@series = Series.find(params[:id])
@series.reorder_list(params[:serial_works])
flash[:notice] = ts("Series order has been successfully updated.")
elsif params[:serial]
params[:serial].each_with_index do |id, position|
SerialWork.update(id, position: position + 1)
(@serial_works ||= []) << SerialWork.find(id)
end
end
respond_to do |format|
format.html { redirect_to series_path(@series) and return }
format.json { head :ok }
end
end
# GET /series/1/confirm_delete
def confirm_delete
end
# DELETE /series/1
# DELETE /series/1.xml
def destroy
if @series.destroy
flash[:notice] = ts("Series was successfully deleted.")
redirect_to(current_user)
else
flash[:error] = ts("Sorry, we couldn't delete the series. Please try again.")
redirect_to(@series)
end
end
private
def series_params
params.require(:series).permit(
:title, :summary, :series_notes, :complete,
author_attributes: [:byline, ids: [], coauthors: []]
)
end
end

View file

@ -0,0 +1,223 @@
class SkinsController < ApplicationController
before_action :users_only, only: [:new, :create, :destroy]
before_action :load_skin, except: [:index, :new, :create, :unset]
before_action :check_ownership_or_admin, only: [:edit, :update]
before_action :check_ownership, only: [:confirm_delete, :destroy]
before_action :check_visibility, only: [:show]
before_action :check_editability, only: [:edit, :update, :confirm_delete, :destroy]
#### ACTIONS
# GET /skins
def index
is_work_skin = params[:skin_type] && params[:skin_type] == "WorkSkin"
if current_user && current_user.is_a?(User)
@preference = current_user.preference
end
if params[:user_id] && (@user = User.find_by(login: params[:user_id]))
redirect_to new_user_session_path and return unless logged_in?
if @user != current_user
flash[:error] = "You can only browse your own skins and approved public skins."
redirect_to skins_path and return
end
if is_work_skin
@skins = @user.work_skins.sort_by_recent.includes(:author).with_attached_icon
@title = ts('My Work Skins')
else
@skins = @user.skins.site_skins.sort_by_recent.includes(:author).with_attached_icon
@title = ts('My Site Skins')
end
else
if is_work_skin
@skins = WorkSkin.approved_skins.sort_by_recent_featured.includes(:author).with_attached_icon
@title = ts('Public Work Skins')
else
@skins = if logged_in?
Skin.approved_skins.usable.site_skins.sort_by_recent_featured.with_attached_icon
else
Skin.approved_skins.usable.site_skins.cached.sort_by_recent_featured.with_attached_icon
end
@title = ts('Public Site Skins')
end
end
end
# GET /skins/1
def show
@page_subtitle = @skin.title.html_safe
end
# GET /skins/new
def new
@skin = Skin.new
if params[:wizard]
render :new_wizard
else
render :new
end
end
# POST /skins
def create
unless params[:skin_type].nil? || params[:skin_type] && %w(Skin WorkSkin).include?(params[:skin_type])
flash[:error] = ts("What kind of skin did you want to create?")
redirect_to :new and return
end
loaded = load_archive_parents unless params[:skin_type] && params[:skin_type] == 'WorkSkin'
if params[:skin_type] == "WorkSkin"
@skin = WorkSkin.new(skin_params)
else
@skin = Skin.new(skin_params)
end
@skin.author = current_user
if @skin.save
flash[:notice] = ts("Skin was successfully created.")
if loaded
flash[:notice] += ts(" We've added all the archive skin components as parents. You probably want to remove some of them now!")
redirect_to edit_skin_path(@skin)
else
redirect_to skin_path(@skin)
end
else
if params[:wizard]
render :new_wizard
else
render :new
end
end
end
# GET /skins/1/edit
def edit
authorize @skin if logged_in_as_admin?
end
def update
authorize @skin if logged_in_as_admin?
loaded = load_archive_parents
if @skin.update(skin_params)
@skin.cache! if @skin.cached?
@skin.recache_children!
flash[:notice] = ts("Skin was successfully updated.")
if loaded
if flash[:error].present?
flash[:notice] = ts("Any other edits were saved.")
else
flash[:notice] += ts(" We've added all the archive skin components as parents. You probably want to remove some of them now!")
end
redirect_to edit_skin_path(@skin)
else
redirect_to @skin
end
else
render action: "edit"
end
end
# Get /skins/1/preview
def preview
flash[:notice] = []
flash[:notice] << ts("You are previewing the skin %{title}. This is a randomly chosen page.", title: @skin.title)
flash[:notice] << ts("Go back or click any link to remove the skin.")
flash[:notice] << ts("Tip: You can preview any archive page you want by tacking on '?site_skin=[skin_id]' like you can see in the url above.")
flash[:notice] << "<a href='#{skin_path(@skin)}' class='action' role='button'>".html_safe + ts("Return To Skin To Use") + "</a>".html_safe
tag = FilterCount.where("public_works_count BETWEEN 10 AND 20").random_order.first.filter
redirect_to tag_works_path(tag, site_skin: @skin.id)
end
def set
if @skin.cached?
flash[:notice] = ts("The skin %{title} has been set. This will last for your current session.", title: @skin.title)
session[:site_skin] = @skin.id
else
flash[:error] = ts("Sorry, but only certain skins can be used this way (for performance reasons). Please drop a support request if you'd like %{title} to be added!", title: @skin.title)
end
redirect_back_or_default @skin
end
def unset
session[:site_skin] = nil
if logged_in? && current_user.preference
current_user.preference.skin_id = AdminSetting.default_skin_id
current_user.preference.save
end
flash[:notice] = ts("You are now using the default Archive skin again!")
redirect_back_or_default "/"
end
# GET /skins/1/confirm_delete
def confirm_delete
end
# DELETE /skins/1
def destroy
@skin = Skin.find_by(id: params[:id])
begin
@skin.destroy
flash[:notice] = ts("The skin was deleted.")
rescue
flash[:error] = ts("We couldn't delete that right now, sorry! Please try again later.")
end
if current_user && current_user.is_a?(User) && current_user.preference.skin_id == @skin.id
current_user.preference.skin_id = AdminSetting.default_skin_id
current_user.preference.save
end
redirect_to user_skins_path(current_user) rescue redirect_to skins_path
end
private
def skin_params
params.require(:skin).permit(
:title, :description, :public, :css, :role, :ie_condition, :unusable,
:font, :base_em, :margin, :paragraph_margin, :background_color,
:foreground_color, :headercolor, :accent_color, :icon,
media: [],
skin_parents_attributes: [
:id, :position, :parent_skin_id, :parent_skin_title, :_destroy
]
)
end
def load_skin
@skin = Skin.find_by(id: params[:id])
unless @skin
flash[:error] = "Skin not found"
redirect_to skins_path and return
end
@check_ownership_of = @skin
@check_visibility_of = @skin
end
def check_editability
unless @skin.editable?
flash[:error] = ts("Sorry, you don't have permission to edit this skin")
redirect_to @skin
end
end
# if we've been asked to load the archive parents, we do so and add them to params
def load_archive_parents
if params[:add_site_parents]
params[:skin][:skin_parents_attributes] ||= ActionController::Parameters.new
archive_parents = Skin.get_current_site_skin.get_all_parents
skin_parent_titles = params[:skin][:skin_parents_attributes].values.map { |v| v[:parent_skin_title] }
skin_parents = skin_parent_titles.empty? ? [] : Skin.where(title: skin_parent_titles).pluck(:id)
skin_parents += @skin.get_all_parents.collect(&:id) if @skin
unless (skin_parents.uniq & archive_parents.map(&:id)).empty?
flash[:error] = ts("You already have some of the archive components as parents, so we couldn't load the others. Please remove the existing components first if you really want to do this!")
return true
end
last_position = params[:skin][:skin_parents_attributes]&.keys&.map(&:to_i)&.max || 0
archive_parents.each do |parent_skin|
last_position += 1
new_skin_parent_hash = ActionController::Parameters.new({ position: last_position, parent_skin_id: parent_skin.id })
params[:skin][:skin_parents_attributes].merge!({last_position.to_s => new_skin_parent_hash})
end
return true
end
false
end
end

View file

@ -0,0 +1,130 @@
class StatsController < ApplicationController
before_action :users_only
before_action :load_user
before_action :check_ownership
# only the current user
def load_user
@user = current_user
@check_ownership_of = @user
end
# gather statistics for the user on all their works
def index
user_works = Work.joins(pseuds: :user).where(users: { id: @user.id }).where(posted: true)
user_chapters = Chapter.joins(pseuds: :user).where(users: { id: @user.id }).where(posted: true)
work_query = user_works
.joins(:taggings)
.joins("inner join tags on taggings.tagger_id = tags.id AND tags.type = 'Fandom'")
.select("distinct tags.name as fandom, works.id as id, works.title as title")
# sort
# NOTE: Because we are going to be eval'ing the @sort variable later we MUST make sure that its content is
# checked against the allowlist of valid options
sort_options = %w[hits date kudos.count comment_thread_count bookmarks.count subscriptions.count word_count]
@sort = sort_options.include?(params[:sort_column]) ? params[:sort_column] : "hits"
@dir = params[:sort_direction] == "ASC" ? "ASC" : "DESC"
params[:sort_column] = @sort
params[:sort_direction] = @dir
# gather works and sort by specified count
@years = ["All Years"] + user_chapters.pluck(:published_at).map { |date| date.year.to_s }
.uniq.sort
@current_year = @years.include?(params[:year]) ? params[:year] : "All Years"
if @current_year == "All Years"
work_query = work_query.select("works.revised_at as date, works.word_count as word_count")
else
next_year = @current_year.to_i + 1
start_date = DateTime.parse("01/01/#{@current_year}")
end_date = DateTime.parse("01/01/#{next_year}")
work_query = work_query
.joins(:chapters)
.where("chapters.posted = 1 AND chapters.published_at >= ? AND chapters.published_at < ?", start_date, end_date)
.select("CONVERT(MAX(chapters.published_at), datetime) as date, SUM(chapters.word_count) as word_count")
.group(:id, :fandom)
end
works = work_query.all.sort_by { |w| @dir == "ASC" ? (stat_element(w, @sort) || 0) : (0 - (stat_element(w, @sort) || 0).to_i) }
# on the off-chance a new user decides to look at their stats and have no works
render "no_stats" and return if works.blank?
# group by fandom or flat view
if params[:flat_view]
@works = {ts("All Fandoms") => works.uniq}
else
@works = works.group_by(&:fandom)
end
# gather totals for all works
@totals = {}
(sort_options - ["date"]).each do |value|
# the inject is used to collect the sum in the "result" variable as we iterate over all the works
@totals[value.split(".")[0].to_sym] = works.uniq.inject(0) { |result, work| result + (stat_element(work, value) || 0) } # sum the works
end
@totals[:user_subscriptions] = Subscription.where(subscribable_id: @user.id, subscribable_type: 'User').count
# graph top 5 works
@chart_data = GoogleVisualr::DataTable.new
@chart_data.new_column('string', 'Title')
chart_col = @sort == "date" ? "hits" : @sort
chart_col_title = chart_col.split(".")[0].titleize == "Comments" ? ts("Comment Threads") : chart_col.split(".")[0].titleize
if @sort == "date" && @dir == "ASC"
chart_title = ts("Oldest")
elsif @sort == "date" && @dir == "DESC"
chart_title = ts("Most Recent")
elsif @dir == "ASC"
chart_title = ts("Bottom Five By #{chart_col_title}")
else
chart_title = ts("Top Five By #{chart_col_title}")
end
@chart_data.new_column('number', chart_col_title)
# Add Rows and Values
@chart_data.add_rows(works.uniq[0..4].map { |w| [w.title, stat_element(w, chart_col)] })
# image version of bar chart
# opts from here: http://code.google.com/apis/chart/image/docs/gallery/bar_charts.html
@image_chart = GoogleVisualr::Image::BarChart.new(@chart_data, {isVertical: true}).uri({
chtt: chart_title,
chs: "800x350",
chbh: "a",
chxt: "x",
chm: "N,000000,0,-1,11"
})
options = {
colors: ["#993333"],
title: chart_title,
vAxis: {
viewWindow: { min: 0 }
}
}
@chart = GoogleVisualr::Interactive::ColumnChart.new(@chart_data, options)
end
private
def stat_element(work, element)
case element.downcase
when "date"
work.date
when "hits"
work.hits
when "kudos.count"
work.kudos.count
when "comment_thread_count"
work.comment_thread_count
when "bookmarks.count"
work.bookmarks.count
when "subscriptions.count"
work.subscriptions.count
when "word_count"
work.word_count
end
end
end

View file

@ -0,0 +1,111 @@
class StatusesController < ApplicationController
before_action :users_only, except: [:index, :show, :timeline]
before_action :set_status, only: [:show, :edit, :update, :destroy]
before_action :set_user, except: [:timeline]
before_action :check_ownership, only: [:edit, :update, :destroy]
def redirect_to_current_user
if current_user
redirect_to user_status_path(current_user)
else
redirect_to new_user_session_path, alert: "Please log in to view statuses."
end
end
def check_ownership
unless @status && @status.user == current_user
redirect_to user_status_path(@user || current_user), alert: "You don't have permission to do that silly!"
end
end
def edit
@user = current_user
end
def update
@user = current_user
if @status.update(status_params)
redirect_to user_status_path(@user, @status), notice: "Status updated!"
else
render :edit
end
end
def show
end
def destroy
@status.destroy
redirect_to user_statuses_path(@user), notice: "Status deleted."
end
def redirect_to_current_user_new
if current_user
redirect_to new_user_status_path(current_user)
else
redirect_to new_user_session_path, alert: "Please log in to create a status."
end
end
def index
if params[:user_id].present?
@user = User.find_by(id: params[:user_id]) || User.find_by(login: params[:user_id])
end
if @user
@statuses = @user.statuses.order(created_at: :desc)
else
@statuses = []
end
end
def new
if @user != current_user
redirect_to user_statuses_path(current_user, @status), alert: "You can't do that silly!"
return
end
@status = current_user.statuses.new
end
def create
if @user != current_user
redirect_to user_status_path(current_user), alert: "You can't do that silly!"
return
end
@status = current_user.statuses.new(status_params)
if @status.save
redirect_to user_statuses_path(current_user, @status), notice: "Status created!"
else
render :new
end
end
def timeline
@statuses = Status.includes(:user, :icon_attachment)
.order(created_at: :desc)
end
private
def set_status
return unless params[:id].present?
@status = Status.find_by(id: params[:id])
unless @status
redirect_to user_statuses_path(current_user), alert: "Status not found."
return
end
@user = @status.user
end
def set_user
return if @user.present?
@user = if params[:user_id].present?
User.find_by(id: params[:user_id]) || User.find_by(login: params[:user_id])
else
current_user
end
unless @user
redirect_to root_path, alert: "User not found."
end
end
def status_params
params.require(:status).permit(:icon, :text, :mood, :music)
end
end

Some files were not shown because too many files have changed in this diff Show more