Authorisation management using CanCanCan added

This commit is contained in:
Christoph Haas 2021-02-28 21:36:46 +01:00
parent 51be4a4777
commit 7a3b65fe50
24 changed files with 211 additions and 295 deletions

View file

@ -143,3 +143,6 @@ gem 'omniauth_openid_connect'
# Mitigate CVE-2015-9284
# https://github.com/cookpad/omniauth-rails_csrf_protection
gem 'omniauth-rails_csrf_protection'
# Role-based access
gem 'cancancan'

View file

@ -90,6 +90,7 @@ GEM
msgpack (~> 1.0)
builder (3.2.4)
byebug (11.1.3)
cancancan (3.2.1)
capybara (3.35.3)
addressable
mini_mime (>= 0.1.3)
@ -430,6 +431,7 @@ DEPENDENCIES
bootsnap
byebug
bzip2-ruby!
cancancan
capybara
cookies_eu
devise

View file

@ -1,38 +1,38 @@
class AdminController < ApplicationController
before_action :authenticate_user!
before_action :admin_only
# before_action :authenticate_user!
# before_action :admin_only
def status
end
# def status
# end
def screenshots
end
# def screenshots
# end
def integration
end
# def integration
# end
def moderate_list
end
# def moderate_list
# end
def logs
logs = Log
# def logs
# logs = Log
if params[:search].present?
logger.debug "Searching for #{params[:search]}"
logs = logs.where("message ilike ?", "%#{params[:search]}%")
end
# if params[:search].present?
# logger.debug "Searching for #{params[:search]}"
# logs = logs.where("message ilike ?", "%#{params[:search]}%")
# end
@logs = logs.paginate(page: params[:page], per_page: 20)
end
# @logs = logs.paginate(page: params[:page], per_page: 20)
# end
private
# private
def admin_only
unless current_user.is_admin?
head :forbidden
# redirect_to :back, :alert => "Access denied."
end
end
# def admin_only
# unless current_user.is_admin?
# head :forbidden
# # redirect_to :back, :alert => "Access denied."
# end
# end
end

View file

@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base
# the navigation bar that contains a paginator of packages that contain
# screenshots that require moderation.
def moderate_packages
if user_signed_in? and current_user.is_admin? and Package.need_moderation.any?
if can? :approve, Screenshot and Package.need_moderation.any?
@moderate_packages = Package.need_moderation
end
end

View file

@ -1,27 +1,22 @@
class ModerateController < ApplicationController
# class ModerateController < ApplicationController
before_action :authenticate_user!
# before_action :authenticate_user!
def index
# First package with pending screenshots
@package = Package.joins(:screenshots).where('screenshots.approved=false or screenshots.markedfordelete=true').distinct(:name).first
# def index
# # First package with pending screenshots
# @package = Package.joins(:screenshots).where('screenshots.approved=false').distinct(:name).first
# # List of screenshots that were reported (to be removed)
# @reported_screenshots = Screenshot.where(markedfordelete: true)
# # First package with reported screenshots
# @reported_package = Package.joins(:screenshots).where('screenshots.markedfordelete=true').distinct(:name).first
# if @package
# # List of screenshots to be moderated
# @pending_screenshots = @package.screenshots_pending
# # Number of screenshots that were already moderated (during this session)
# session[:already_moderated] ||= 0
# @moderated_screenshots = session[:already_moderated]
if @package
# List of screenshots to be moderated
@pending_screenshots = @package.screenshots_pending
# Number of screenshots that were already moderated (during this session)
session[:already_moderated] ||= 0
@moderated_screenshots = session[:already_moderated]
# BUG: calculation is wrong when pending and reported screenshots are in the queue
# Percentage of already moderated screenshots
@percent_moderated = 100 * (@moderated_screenshots+1) / (@moderated_screenshots + @pending_screenshots.count)
# @percent_moderated = 100 * (@moderated_screenshots+1) / (@moderated_screenshots + @pending_screenshots.count + @reported_screenshots.count)
end
end
end
# # BUG: calculation is wrong when pending and reported screenshots are in the queue
# # Percentage of already moderated screenshots
# @percent_moderated = 100 * (@moderated_screenshots+1) / (@moderated_screenshots + @pending_screenshots.count)
# # @percent_moderated = 100 * (@moderated_screenshots+1) / (@moderated_screenshots + @pending_screenshots.count + @reported_screenshots.count)
# end
# end
# end

View file

@ -37,10 +37,6 @@ class PackagesController < ApplicationController
# This action saves the screenshots already if they are valid. The user is
# then given the chance to comment on and delete the screenshots again.
def upload_receive
# Create a pseudo user account for the user.
# The user won't know that an account is created.
# But this makes it easier to track who screenshots belong to.
# User.create_pseudo_user unless user_signed_in?
@package = Package.find_by!(name: params[:name])
@valid_images = []
@invalid_images = []
@ -65,7 +61,7 @@ class PackagesController < ApplicationController
# Check if the image was valid
if new_screenshot.valid?
new_screenshot.uploaderhash = session.id.to_s
# new_screenshot.uploaderhash = session.id.to_s
new_screenshot.uploaderip = request.remote_ip
new_screenshot.version = @package.version
# ActiveStorage does not yet create a file checksum automatically.
@ -77,19 +73,25 @@ class PackagesController < ApplicationController
Log.log "Duplicate image with fingerprint #{new_screenshot.image_fingerprint} found. Rejecting."
all_errors << "Your file #{img.original_filename} is a duplicate. Sorry."
else
# Can the upload get approved automatically?
if user_signed_in?
new_screenshot.user = current_user
new_screenshot.approve! if auto_approve?
end
# TODO: Can the upload get approved automatically?
# Create a pseudo user account for the user.
# The user won't know that an account is created.
# But this makes it easier to track who screenshots belong to.
unless user_signed_in?
sign_in User.create_pseudo_user
end
new_screenshot.user = current_user
new_screenshot.approve! if can?(:approve, new_screenshot)
# Pre-render screenshot in different sizes
new_screenshot.simage_derivatives!
Log.log "Derivatives created"
new_screenshot.save!
Log.log "Screenshot #{new_screenshot.id} uploaded successfully. " + \
"ip=#{new_screenshot.uploaderip}. "+ \
"user-hash=#{new_screenshot.uploaderhash}. "+ \
#"user-hash=#{new_screenshot.uploaderhash}. "+ \
"user-name=#{current_user} "+ \
"image-fingerprint=#{new_screenshot.image_fingerprint} "+ \
"image-path=#{img.path}"
@ -174,7 +176,7 @@ class PackagesController < ApplicationController
# Check if the user is allowed to change this screenshot
# - Is this the user's own screenshot? (anonymous)
if @screenshot.user == current_user or current_user.is_admin?
if (@screenshot.user == current_user) || (can? :detroy, @screenshot)
logger.debug "User #{current_user} deletes screenshot #{@screenshot}"
@screenshot.destroy
flash['notice'] = "Screenshot deleted."
@ -185,8 +187,9 @@ class PackagesController < ApplicationController
end
def approve_screenshot
if current_user && current_user.is_admin?
@screenshot = Screenshot.find(params[:id])
@screenshot = Screenshot.find(params[:id])
if can? :approve, @screenshot
@screenshot.approve!
flash['notice'] = "Screenshot approved."
redirect_back(fallback_location: package_path(name: @screenshot.package.name))
@ -263,23 +266,6 @@ class PackagesController < ApplicationController
redirect_back(fallback_location: package_path(name: @screenshot.package.name))
end
# Receive an anonymous report from a user to have a screenshot removed.
# def report_screenshot
# @screenshot = Screenshot.find(params[:id])
# # if verify_recaptcha
# @screenshot.delete_reason = params[:delete_reason]
# @screenshot.markedfordelete = true
# if @screenshot.valid?
# @screenshot.save!
# flash['notice'] = "Screenshot reported. The moderators will deal with it."
# else
# errors = @screenshot.errors.to_a.join(' and ')
# flash['alert'] = "Sorry. #{errors}"
# end
# # end
# redirect_back(fallback_location: package_path)
# end
# Show an HTML partial with reviews of this package from the Ubuntu API
# def reviews
# expires_in 1.day, public: true
@ -350,37 +336,7 @@ class PackagesController < ApplicationController
params.require(:screenshot).permit(:description)
end
# Do uploads from this user get approved automatically?
def auto_approve?
# Anonymous users need to go through moderation
return false unless user_signed_in?
# Admins do not need moderation
return true if current_user.is_admin?
# Debian developers do not need moderation
return true if current_user.provider == 'debian-sso'
# After one successfully approved screenshot users do not need moderation
# return true if current_user.approved_screenshots.count > 0
# Any other user's upload must be moderated
return false
end
def screenshots_visible_to_user(package)
if user_signed_in? and current_user.is_admin?
# User is an admin and can view all screenshots
package.screenshots
# TODO: User logins and assigned screenshots will come in a later version
# elsif user_signed_in?
# package.screenshots.where(id: (current_user.screenshots.select(:id))) | \
# package.screenshots.where(approved: true).order('created_at DESC')
else
package.screenshots.where(uploaderhash: session.id.to_s).or(
package.screenshots.where(approved: true)
)
.order('created_at DESC')
end
package.screenshots.accessible_by(current_ability, :view).order('created_at DESC')
end
end

View file

@ -1,45 +1,5 @@
module PackagesHelper
# Return a query of all screenshots that the current user may see
# Consists of:
# - approved (public) screenshots
# - screenshots uploaded by the user
# - all screenshots if the user is an admin
# def screenshots_visible_to_user(package)
# if user_signed_in? and current_user.is_admin?
# # User is an admin and can view all screenshots
# package.screenshots
# # TODO: User logins and assigned screenshots will come in a later version
# # elsif user_signed_in?
# # package.screenshots.where(id: (current_user.screenshots.select(:id))) | \
# # package.screenshots.where(approved: true).order('created_at DESC')
# else
# package.screenshots.where(uploaderhash: session.id.to_s) | \
# package.screenshots.where(approved: true).order('created_at DESC')
# end
# end
def screenshots_visible_to_user(package, user_session)
# Show all screenshots to admins
if current_user && current_user.is_admin?
package.screenshots
# Show screenshots belonging to the user or public/approved
else
package.screenshots.where(
uploaderhash: user_session.id.to_s
).or(
package.screenshots.where(
approved: true
)
)
end
end
def screenshot_uploaded_by_current_user?(screenshot)
screenshot.uploaderhash == session.id.to_s
end
# Return the description and/or the version of the package
def screenshot_caption(screenshot)
str = []
@ -78,9 +38,7 @@ module PackagesHelper
def status_text(screenshot)
# TODO: markedfordelete/reporting will be removed
if screenshot.markedfordelete
"Removal requested > #{screenshot.delete_reason}"
elsif screenshot.approved
if screenshot.approved
fa_icon('check-square') + ' Public'
else
fa_icon('hourglass') + ' Waiting for approval'

56
app/models/ability.rb Normal file
View file

@ -0,0 +1,56 @@
# frozen_string_literal: true
class Ability
include CanCan::Ability
def initialize(user)
# Define abilities for the passed in user here. For example:
#
# user ||= User.new # guest user (not logged in)
# if user.admin?
# can :manage, :all
# else
# can :read, :all
# end
#
# The first argument to `can` is the action you are giving the user
# permission to do.
# If you pass :manage it will apply to every action. Other common actions
# here are :read, :create, :update and :destroy.
#
# The second argument is the resource the user can perform the action on.
# If you pass :all it will apply to every resource. Otherwise pass a Ruby
# class of the resource.
#
# The third argument is an optional hash of conditions to further filter the
# objects.
# For example, here the user can only update published articles.
#
# can :update, Article, :published => true
#
# See the wiki for details:
# https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities
if user.present? # Logged-in users
if user.admin_role?
can :approve, Screenshot
can :destroy, Screenshot
can :destroy, User
can :view, Screenshot
can :destroy, Package
end
if user.moderator_role?
can :approve, Screenshot
can :view, Screenshot
can :destroy, Screenshot
end
if user.pseudo?
# Allow to view all public/approved screenshots
can :view, Screenshot, approved: true
# Allow to view any own uploads (even not-yet-approved)
can :view, Screenshot, user_id: user.id
end
end
end
end

View file

@ -53,7 +53,7 @@ class Package < ApplicationRecord
# Return a list of packages that have screenshots to be moderated
def self.need_moderation
Package.joins(:screenshots).where('screenshots.approved=false or screenshots.markedfordelete=true').distinct(:name)
Package.joins(:screenshots).where('screenshots.approved=false').distinct(:name)
end
@ -71,7 +71,7 @@ class Package < ApplicationRecord
# Return a query of all approved/public screenshots of this package
def screenshots_pending
self.screenshots.where('approved=false or markedfordelete=true')
self.screenshots.where('approved=false')
end
# Return a list of packages that have unapproved screenshots

View file

@ -10,11 +10,6 @@ class Screenshot < ApplicationRecord
# Shrine
include ImageUploader::Attachment(:simage) # adds an `simage` virtual attribute
# Calculate how many days ago this screenshot has been uploaded
def age
time_ago_in_words(self.created_at)
end
# Return caption for full-screen screenshots.
# Takes the description of a screenshot if available.
# Otherwise it falls back to the general description of its package.
@ -54,19 +49,16 @@ class Screenshot < ApplicationRecord
# Brief text describing the status of this screenshots (for admins)
def adminstatus
if self.markedfordelete
"Removal requested > #{self.delete_reason}"
elsif self.approved
if self.approved
'Public'
else
fa_icon('hourglass') + 'Waiting for approval'
#fa_icon('hourglass') + 'Waiting for approval'
'Waiting for approval'
end
end
# Publish a screenshot from the moderation queue
def approve!
self.delete_reason = nil
self.markedfordelete = false
self.approved = true
self.save!
end
@ -76,35 +68,6 @@ class Screenshot < ApplicationRecord
self.order(created_at: :desc).where(approved: true).first
end
# Returns true if the current user has administrative permissions
# def can_admin?
# self.admin == 1
# end
# Returns true if the current user can upload screenshots without moderation
# def can_upload_without_moderation?
# # Admins can upload without moderation
# true if self.can_admin
# # Authenticated users with at least one approved screenshot
# #true if self.screenshots.where(approved: true).count >= 1
# # Other visitors require moderation
# false
# end
# Returns true if the current user can delete screenshots
# def can_delete?
# # Admins can delete screenshots
# true if self.can_admin
# # Authenticated users with at least one approved screenshot
# #true if self.screenshots.where(approved: true).count >= 1
# # Other visitors require moderation
# false
# end
# Return the part of the version up to the first - or +
def upstream_version
self.version.split(/[\-\+]/).first

View file

@ -40,10 +40,6 @@ class User < ApplicationRecord
end
end
def is_admin?
self.admin == 1
end
# Check if a user has been created on-the-fly and is just an
# anonymous user who uploaded a screenshot. They can turn this
# user record into a registered account though.
@ -59,6 +55,11 @@ class User < ApplicationRecord
user.name = auth.info.name
# Set a random password
user.password = Devise.friendly_token[0,20]
# Users coming through salsa.debian.net/OpenID-Connect will get moderator role
if user.email.end_with?('@debian.org') && user.provider=='salsa'
user.moderator_role = true
end
end
end
@ -66,20 +67,18 @@ class User < ApplicationRecord
self.screenshots.where(approved: true)
end
# Seamlessly create a user account for the current client.
# Seamlessly create a user account for the current visitor.
# It helps track uploads because uploaded screenshots get assigned
# to this user record. The user can later decide to use a real
# account and get their screenshots transferred to that.
# def self.create_pseudo_user
# generated_password = Devise.friendly_token.first(8)
# new_user = User.create(
# name: 'Anonymous',
# password: generated_password)
# Log.log "New pseudo user for anonymous upload created: #{new_user}"
# sign_in new_user
# end
#
# Currently the approach is different: do not create an account
# for a user. Instead store the IDs of the uploaded screenshots
# and transfer them if the user decides to do a real login using SSO.
# to this user record.
# TODO: The user can later decide to use a real account and get their screenshots transferred to that.
def self.create_pseudo_user
generated_password = Devise.friendly_token.first(8)
new_user = User.create(
name: 'Anonymous',
password: generated_password,
pseudo: true
)
Log.log "New pseudo user for anonymous upload created: #{new_user}"
return new_user
end
end

View file

@ -15,6 +15,3 @@
/ ' | Cookie is #{session[:token]}
= render 'cookies_eu/consent_banner', link: '/about#cookies'
/ = current_user.is_admin?
/ = session.id

View file

@ -31,12 +31,6 @@
a.button.alert.round href=delete_screenshot_path(screenshot) onclick="return confirm('Really delete the screenshot?');" Reject
a.button.success.round href=approve_screenshot_path(screenshot) Approve
/ Existing screenshots that was requested for removal
- if screenshot.markedfordelete==true
.button-group
a.button.alert.round href=delete_screenshot_path(screenshot) onclick="return confirm('Really delete the screenshot?');" Remove as requested
a.button.success.round href=approve_screenshot_path(screenshot) Keep the screenshot
.small-6.cell
p Existing screenshots:

View file

@ -7,11 +7,11 @@
= link_to moderate_list_path
= fa_icon 'check 2x', text: 'Moderate'
// Add link if user has uploaded screenshots
- if @current_users_screenshots and @current_users_screenshots.any?
li class=('active' if controller_name=='my' and action_name=='uploads')
= link_to my_uploads_path
= fa_icon 'image 2x', text: 'My uploads'
' My uploads
/ - if @current_users_screenshots and @current_users_screenshots.any?
/ li class=('active' if controller_name=='my' and action_name=='uploads')
/ = link_to my_uploads_path
/ = fa_icon 'image 2x', text: 'My uploads'
/ ' My uploads
li
= link_to destroy_user_session_path, method: :delete

View file

@ -27,8 +27,8 @@
h2 Number of screenshots you uploaded
p = current_user.screenshots.count
- if current_user.is_admin?
h2 Admin
p Apparently you are an administrator. Be careful with that thing!
tt = current_user.inspect
/ - if current_user.is_admin?
/ h2 Admin
/ p Apparently you are an administrator. Be careful with that thing!
/ - you can moderate
/ - you can …

View file

@ -16,8 +16,8 @@
- @current_users_screenshots.each do |screenshot|
.column
/ TODO: old Paperclip style
a.black.fancybox href=screenshot.image.variant(resize_to_limit: [800,600], timestamp: false)
a.black.fancybox href=screenshot.simage_url(:large)
div.grid-thumbnail
= image_tag(screenshot.image.variant(resize_to_limit: [160,120]), alt: screenshot.caption, class: 'thumbnail')
= image_tag(screenshot.simage_url(:medium), alt: screenshot.caption, class: 'thumbnail')
div
= screenshot.description

View file

@ -1,7 +1,13 @@
// Button that reveals a dropdown/modal for moderation/reporting
- if can?(:destroy, screenshot) || can?(:approve, screenshot)
.text
span.label.secondary
= status_text(screenshot)
.button-group.small.align-center
- if (screenshot_uploaded_by_current_user?(screenshot) || (current_user && current_user.is_admin?))
- if can?(:destroy, screenshot)
a.button.small.bordered.radius.alert[
href=delete_screenshot_path(screenshot.id)
onclick="return confirm('Really delete the screenshot?');"
@ -15,29 +21,15 @@
/ href='#'
/ ] #{fa_icon 'star'} Make primary
- if not screenshot.approved
- if current_user && current_user.is_admin?
- unless screenshot.approved
- if can?(:approve, screenshot)
a.button.small.success[
href=approve_screenshot_path(screenshot.id)
method='post'
] Approve
/ - elsif screenshot.markedfordelete
/ a.button.small.success[
/ href=approve_screenshot_path(screenshot.id)
/ method='post'
/ ] Keep
] #{fa_icon 'check'} Approve
/ Display additional information to admins
p Status: #{screenshot.adminstatus}
p Uploader IP=#{screenshot.uploaderip}
p Uploader Token=#{session[:token]}
p Uploaded #{screenshot.age} ago (#{screenshot.created_at})
/ TODO: Move reporting screenshots to an extra page with a form and captcha
/ - else
/ / Allow anonymous users to report inappropriate screenshots
/ = form_tag(report_screenshot_path(screenshot.id))
/ = text_area_tag 'delete_reason', nil, class: 'input-group-field', maxlength: 100, rows: 3, cols: 50
/ = submit_tag 'Request removal', class: 'button alert'
/ p Status: #{screenshot.adminstatus}
/ p Uploader IP=#{screenshot.uploaderip}
/ p Uploader Token=#{session[:token]}
/ p Uploaded #{time_ago_in_words(screenshot.created_at)} ago (#{screenshot.created_at})

View file

@ -1,12 +0,0 @@
// Button that reveals a dropdown/modal for users (for their own screenshots)
.text-right
button.small.dropdown.warning.button type="button" data-toggle="admin-info-#{screenshot.id}"
'Request removal
.dropdown-pane data-dropdown=true id="admin-info-#{screenshot.id}"
p If you think this screenshot does not meet the guidelines
or is misleading or not useful then please report it so that
the moderators can review it. Just let us know why you think
the screenshot should get removed:
= form_tag(report_screenshot_path(screenshot.id))
= text_area_tag 'delete_reason', nil, class: 'input-group-field', maxlength: 100, rows: 3, cols: 50
= submit_tag 'Request removal', class: 'button alert'

View file

@ -25,24 +25,13 @@
.text
= screenshot.caption
// Has the unmoderated screenshot been uploaded by the current user?
- if screenshot_uploaded_by_current_user?(screenshot)
- if current_user.screenshots.include?(screenshot)
.text
span.label.secondary
' Uploaded by you
- unless screenshot.approved
' (needs to be approved)
= render(partial: 'admin_buttons', locals: {screenshot: screenshot})
// Is the user an admin?
// or does the screenshot belong to the user (determined by session cookie)
- if current_user && current_user.is_admin?
hr
.text
span.label.secondary
= status_text(screenshot)
= render(partial: 'admin_buttons', locals: {screenshot: screenshot})
/ - elsif user_signed_in? and screenshot.user == current_user
/ = render(partial: 'user_dropdown', locals: {screenshot: screenshot})
= render(partial: 'admin_buttons', locals: {screenshot: screenshot})
// Second paginator at the bottom so the user does not have to scroll up again

View file

@ -0,0 +1,10 @@
class DropDeprecatedScreenshotsFields < ActiveRecord::Migration[6.1]
def change
remove_column :screenshots, :markedfordelete
remove_column :screenshots, :delete_reason
remove_column :screenshots, :image_file_name
remove_column :screenshots, :image_content_type
remove_column :screenshots, :image_file_size
remove_column :screenshots, :image_updated_at
end
end

View file

@ -0,0 +1,14 @@
# Rename the :admin field to :admin_role and change integer to boolean
class AddRolesToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :admin_role, :boolean, default: false
User.find_each do |user|
user.admin_role=true if user.admin>0
user.save!
end
remove_column :users, :admin
add_column :users, :moderator_role, :boolean, default: false
end
end

View file

@ -0,0 +1,8 @@
# Create a pseudo flag for accounts that get automatically created.
# If an anonymous visitor uploads a screenshot he will get a
# pseudo account to assign the screenshots to.
class AddPseudoColumnToUser < ActiveRecord::Migration[6.1]
def change
add_column :users, :pseudo, :boolean, default: false
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_02_22_102719) do
ActiveRecord::Schema.define(version: 2021_02_28_191717) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -88,14 +88,8 @@ ActiveRecord::Schema.define(version: 2021_02_22_102719) do
t.datetime "created_at"
t.string "uploaderhash", limit: 72
t.boolean "approved", default: false, null: false
t.boolean "markedfordelete"
t.string "delete_reason", limit: 100
t.text "description"
t.datetime "updated_at"
t.string "image_file_name"
t.string "image_content_type"
t.integer "image_file_size"
t.datetime "image_updated_at"
t.string "image_fingerprint"
t.integer "user_id", default: 0
t.text "simage_data"
@ -120,7 +114,9 @@ ActiveRecord::Schema.define(version: 2021_02_22_102719) do
t.datetime "locked_at"
t.string "provider"
t.string "uid"
t.integer "admin", default: 0
t.boolean "admin_role", default: false
t.boolean "moderator_role", default: false
t.boolean "pseudo", default: false
t.index ["email", "provider"], name: "index_users_on_email_and_provider", unique: true
end

View file

@ -6,8 +6,6 @@
# uploaderhash: MyString
# uploaderip: MyString
# approved:
# markedfordelete:
# delete_reason: MyString
# description: MyString
# two:
@ -16,6 +14,4 @@
# uploaderhash: MyString
# uploaderip: MyString
# approved:
# markedfordelete:
# delete_reason: MyString
# description: MyString