1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# frozen_string_literal: true

require "roda"

require_relative "models"
require_relative "proxy_auth"
require_relative "inner_tube"
require_relative "views/home"
require_relative "views/list/new"
require_relative "views/list/show"
require_relative "views/list/compare"
require_relative "views/list/settings"

module RankKing
  class Web < Roda
    plugin :halt
    plugin :static, %w[ /css /js /vendor ]
    plugin :sessions, secret: CONFIG.session_secret
    plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
    plugin :error_handler, classes: [ProxyAuth::MissingLogin, InnerTube::Error, Sources::Invalid] do |e|
      response["Content-Type"] = "text/plain"
      if e.is_a?(Sources::Invalid)
        response.status = 422
        "#{e.message}\n"
      elsif e.is_a?(InnerTube::Error)
        response.status = 502
        "YouTube: #{e.message}\n"
      else
        response.status = 401
        "Unauthorized: #{e.message}.\n"
      end
    end

    route do |r|
      check_csrf!
      user = User.find_or_create(login: ProxyAuth.login(r.env))

      r.root do
        lists = user.lists_dataset.order(:id).all
        Views::Home.new(lists: lists).call
      end

      providers.each_value { |provider| provider.routes(r) }

      r.on "lists" do
        r.is do
          r.post do
            name = r.params["name"].to_s.strip
            r.halt 422 if name.empty?

            source_kind = r.params["source"] || "Text"
            provider = providers[source_kind] or r.halt 422

            axis_names = names_param(r.params["axes"])

            # The source fills the list from its own fields, or raises and
            # nothing is made.
            list = DB.transaction {
              l = List.create(name: name, user_id: user.id, source_kind: source_kind)
              provider.create(l, r.params)

              axis_names.each do |n|
                Axis.create(list_id: l.id, name: n)
              end
              l
            }

            r.redirect "/lists/#{list.id}"
          end
        end

        r.get "new" do
          sources = providers.map { |name, provider|
            [name, provider.title, provider.fields(csrf: method(:csrf_token))] #: [String, String, Sources::_Component]
          }
          Views::ListViews::New.new(sources:, csrf: method(:csrf_token)).call
        end

        r.on Integer do |list_id|
          # Lists are private to their owner.
          list = user.lists_dataset.where(id: list_id).first
          r.halt 404 unless list

          r.is do
            r.get do
              axis = list.axes_dataset.where(id: r.params["axis"].to_i).first
              Views::ListViews::Show.new(list: list, source: source(list), axis: axis, csrf: method(:csrf_token)).call
            end
          end

          r.is "settings" do
            r.get do
              source = provider(list).title
              Views::ListViews::Settings.new(list: list, source: source, csrf: method(:csrf_token)).call
            end

            r.post do
              name = r.params["name"].to_s.strip
              r.halt 422 if name.empty?

              kept = Array(r.params["axis_ids"]) #: Array[untyped]
              kept_ids = kept.map { |id| id.to_i }
              new_names = names_param(r.params["axes"])

              DB.transaction do
                list.update(name: name)
                list.axes_dataset.exclude(id: kept_ids).each { |axis| axis.destroy }
                new_names.each { |n| Axis.create(list_id: list.id, name: n) }
              end

              r.redirect "/lists/#{list.id}"
            end
          end

          # Submitted from the source's own sync dialog.
          r.post "sync" do
            source(list).sync(r.params)
            r.redirect "/lists/#{list.id}"
          end

          r.post "delete" do
            DB.transaction { list.destroy }
            r.redirect "/"
          end

          r.post "axes" do
            name = r.params["name"].to_s.strip
            r.halt 422 if name.empty?

            axis = Axis.create(list_id: list.id, name: name)
            r.redirect "/lists/#{list.id}?axis=#{axis.id}"
          end

          r.get "axes", Integer, "compare" do |axis_id|
            axis = list.axes_dataset.where(id: axis_id).first
            r.halt 404 unless axis

            Views::ListViews::Compare.new(list: list, source: source(list), axis: axis, csrf: method(:csrf_token)).call
          end

          r.on "results" do
            r.post do
              axis_id = r.params["axis_id"].to_i
              winner_id = r.params["winner_id"].to_i
              loser_id = r.params["loser_id"].to_i
              tie = r.params["tie"]
              r.halt 422 unless tie.nil? || tie == "1"

              axis = list.axes_dataset.where(id: axis_id).first
              r.halt 422 unless axis

              winner = list.items_dataset.where(id: winner_id).first
              loser = list.items_dataset.where(id: loser_id).first
              r.halt 422 unless winner && loser
              r.halt 422 if winner.id == loser.id

              Result.create(axis_id: axis.id, winner_id: winner.id, loser_id: loser.id, tie: !tie.nil?)

              r.redirect "/lists/#{list.id}/axes/#{axis.id}/compare"
            end
          end
        end
      end
    end

    private

    # What the app runs with. Tests subclass the app to swap in fakes.
    #: () -> Config
    def config = CONFIG

    #: () -> Hash[String, Sources::_Provider]
    def providers = @providers ||= Sources.providers(config)

    #: (List list) -> Sources::_Provider
    def provider(list) = providers.fetch(list.source_kind)

    #: (List list) -> Sources::_Source
    def source(list) = provider(list).source(list)

    # Names from a repeated field (`axes[]`), one per tag.
    #: (untyped) -> Array[String]
    def names_param(value)
      names = Array(value) #: Array[untyped]
      names.map { |n| n.to_s.strip }.reject(&:empty?).uniq
    end
  end
end