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
require "pro_tacts/admin/device_setup"
require "pro_tacts/profile"

module ProTacts
  class Web < Roda
    # The configuration profile, served so the device reading this can
    # provision itself, and the only renderer of it: rake
    # profile:install downloads from here. The screen and the document
    # are separate paths because a
    # mobileconfig is not a page — the screen says what the download
    # will do, and only the link fires the install flow.
    #
    # The hostname is the request's rather than PRO_TACTS_HOSTNAME:
    # the address a device reached this app at is reachable from that
    # device by construction, where an environment variable is a claim
    # about some other machine's idea of where we live. It is the
    # client's Host header, which the proxy in front passes along
    # (ProxyAuth) — and past the identity gate in web.rb's trunk, the only
    # devices asking are on the tailnet.
    hash_branch("setup") do |r|
      r.is do
        r.get do
          response["Content-Type"] = "text/html; charset=utf-8"
          Admin::DeviceSetup.call(hostname: r.host, login: @login, name: ProTacts.config.instance_name)
        end
      end

      # The media type is what starts the install: a device offers to
      # install a profile because of what the response is, not what the
      # path is called. The path carries .mobileconfig anyway, so a
      # download that does land in a file system lands under the name
      # the rake task writes. No Content-Disposition — an attachment
      # disposition on this type is untested here, and the install flow
      # is the point.
      r.get "carddav.mobileconfig" do
        response["Content-Type"] = "application/x-apple-aspen-config"
        Profile.render(hostname: r.host, username: @login)
      end

      # Names the requester's own book, `rake book:name` for whoever is
      # asking (docs/plans/2026-09-16-book-names.md); no name goes back
      # to the login. Plain text, for curl rather than a screen.
      r.post "book" do
        response["Content-Type"] = "text/plain"
        begin
          store.name_book(@login, typecast_params.str("name"))
          "#{@login} syncs #{Group::SYNC_PREFIX}#{store.book_name(@login)}\n"
        rescue Sequel::UniqueConstraintViolation, Store::EveryonesBookName
          # Another login's name, a group's already, or everyone's.
          response.status = 409
          "#{Group::SYNC_PREFIX}#{typecast_params.str('name', '').strip} is taken; #{@login}'s book is unchanged.\n"
        end
      end
    end
  end
end