#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#
# puavo-sharedir-manager handles permissions to shared directories.
# Schools and groups are looked up from LDAP and a directory hierarchy
# under /home/share/.share is created.  A set of symbolic link hierarchies
# is created for each supported language under its own directory.
#
# Permissions are kept under control with inotify and ACLs.
# Every time the script is started, fresh list of schools/groups
# is looked up from LDAP, and all file permissions are fixed.
# The inotify-eventloop is exited once a night and a "restart" is simulated,
# where LDAP is looked up again and all file permissions are checked again.
# Normally all file permissions should be fixed right away as file events
# occur.

require 'etc'
require 'find'
require 'open3'
require 'pathname'
require 'puavo/sharedir/ldap'
require 'puavo/sharedir/shared_directories'
require 'rb-inotify'
require 'syslog'

Languages             = %w(de en fi sv)
ShareDirBase          = '/home/share'
ShareDirBaseFileStore = "#{ ShareDirBase }/.share"

def is_parent_of?(subpath, dir)
  subpath[0, dir.length] == dir
end

class PuavoShareSpec
  attr_accessor :automatic_acls,
                :group,
                :readgroups,
                :school,
                :sharepath,
                :writegroups,
                :writeonlygroups

  def initialize(kwargs = {})
    @automatic_acls  = kwargs[:automatic_acls]
    @group           = kwargs[:group]
    @readgroups      = kwargs[:readgroups]
    @school          = kwargs[:school]
    @sharepath       = kwargs[:sharepath]
    @writegroups     = kwargs[:writegroups]
    @writeonlygroups = kwargs[:writeonlygroups]
  end

  def linkpath(lang)
    basename   = PuavoSharedDirectories.dirname('base', lang)
    schoolpart = PuavoSharedDirectories.detox(@school.cn)
    grouppart  = \
      @group ? PuavoSharedDirectories.detox(@group.displayName) \
             : PuavoSharedDirectories.dirname(File.basename(@sharepath), lang)

    [ ShareDirBase, basename, schoolpart, grouppart ].join('/')
  end
end

class PuavoShare
  def initialize(sharedirspec)
    @spec = sharedirspec

    @wanted_directory_acls = make_wanted_acls('rwx', 'r-x', 'rwx', true)
    @wanted_file_acls      = make_wanted_acls('rw-', 'r--', '---', false)
  end

  def self.delete_link(path)
    # delete link in a somewhat safe way
    if not path.match(/^#{ ShareDirBase }/) then
      raise "An internal error, trying to delete '#{ path }' (link)"
    end
    begin
      File.delete(path)
    rescue Errno::ENOENT
    end
  end

  def automatic_acls
    @spec.automatic_acls
  end

  def create_sharedir
    self.class.mkdir(@spec.sharepath, 0)

    Languages.each do |lang|
      link_path = @spec.linkpath(lang)

      begin
        PuavoShare.mkdir(File.dirname(link_path), 0755)

        current_linkdest = File.readlink(link_path) rescue nil
        link_dir = Pathname(link_path).dirname()
        expected_linkdest = Pathname(@spec.sharepath) \
                              .relative_path_from(link_dir).to_s

        next if current_linkdest == expected_linkdest

        self.class.delete_link(link_path)
        File.symlink(expected_linkdest, link_path)
        Syslog.notice('%s',
          "Made link from '#{ link_path }' to '#{ expected_linkdest }'.")
      end 
    end
  end

  def fix_acls(path)
    return unless @spec.automatic_acls

    begin
      actual_acls = getfacl(path)
    rescue StandardError => e
      Syslog.warning('%s', e.message)
      return
    end

    wanted_acls = get_wanted_acls(path)

    # getfacl() returns empty in case of symbolic links, only change their
    # ownerships (permissions on symbolic links do not matter).
    if actual_acls.empty? then
      begin
        if File.symlink?(path) then
          lstat = File.lstat(path)
          if lstat.uid != 0 or lstat.gid != 0 then
            File.lchown(0, 0, path)
          end
        end
      rescue
      end
      return
    end

    # Only change things if change is needed.
    return if wanted_acls == actual_acls

    reset_acls(path, wanted_acls)
  end

  def fix_acls_recursively(path=nil)
    return unless @spec.automatic_acls

    Find.find(path || @spec.sharepath) { |path| fix_acls(path) }
  end

  def getfacl(path)
    acls = ''
    cmd = [ 'getfacl', '-P', '-p', '-n', '--', path ]
    Open3.popen3(*cmd) do |stdin, stdout, stderr|
      acls = stdout.readlines.select { |l| not l =~ /^# file:/ }.join('')
      errormsg = stderr.read
      if !errormsg.empty? then
        raise "Could not get ACLs for #{ path }: #{ errormsg }"
      end
    end
    normalize_acl(acls)
  end

  def get_wanted_acls(filename)
    if File.directory?(filename) then
      @wanted_directory_acls
    else
      @wanted_file_acls
    end
  end

  def make_wanted_acls(rwmode, romode, womode, add_defaults)
    return unless @spec.automatic_acls

    wgids  = @spec.writegroups.map     { |g| Etc.getgrnam(g).gid }
    rgids  = @spec.readgroups.map      { |g| Etc.getgrnam(g).gid }
    wogids = @spec.writeonlygroups.map { |g| Etc.getgrnam(g).gid }

    acl_array = [
      "# owner: 0",
      "# group: 0",
      "user::#{ rwmode }",
      'group::---',
      wgids.map  { |group| "group:#{ group }:#{ rwmode }" },
      rgids.map  { |group| "group:#{ group }:#{ romode }" },
      wogids.map { |group| "group:#{ group }:#{ womode }" },
      "mask::#{ rwmode }",
      'other::---',

      add_defaults ? [
        'default:user::rwx',
        'default:group::---',
        wgids.map  { |group| "default:group:#{ group }:#{ rwmode }" },
        rgids.map  { |group| "default:group:#{ group }:#{ romode }" },
        wogids.map { |group| "default:group:#{ group }:---"         },
            #^ any subdirectories should be created with max restrictions, hence ---
        "default:mask::#{ rwmode }",
        'default:other::---',
      ] : nil,
    ].compact.flatten

   normalize_acl(acl_array.map { |l| "#{ l }\n" }.join(''))
  end

  def self.mkdir(path, mode)
    begin
      Dir.mkdir(path, mode)
      Syslog.notice('%s', "Created directory #{ path }.")
    rescue Errno::EEXIST
    end
  end

  def normalize_acl(acls)
    acls.split("\n").sort.join("\n")
  end

  def parent_of?(path)
    is_parent_of?(path.to_s, @spec.sharepath)
  end

  def reset_acls(filename, wanted_acls)
    raise 'This should never be called unless automatic_acls is set' \
      unless @spec.automatic_acls

    if parent_of?(filename) then
      begin
        uid = File.stat(filename).uid
        Syslog.info('%s', "Reset ACLs for #{ filename } (uid=#{ uid }).")

        # First set ACLs and then change owner to root.  This is to allow
        # "cp -R $src $target" and similar to work, so that at every moment
        # a user has a write permission to directories.
        setfacl(filename, wanted_acls)
        File.chown(0, 0, filename)

      rescue Errno::ENOENT
      rescue StandardError => e
        msg = "Error in resetting permissions for #{ filename }: " \
              + "#{ e.message }: #{ e.backtrace.inspect }."
        Syslog.err('%s', msg)
      end
    else
      msg = "Internal error: called reset_acls() with path " \
            + "#{ filename } that is not under #{ @spec.sharepath }."
      Syslog.warning('%s', msg)
    end
  end

  def setfacl(path, wanted_acls)
    # "-P" is good and works (despite the manual page that says otherwise).
    # We want this to avoid changing the permissions of files symbolic links
    # may point to.
    cmd = [ 'setfacl', '-P', '--set-file=-', '--', path ]
    Open3.popen3(*cmd) do |stdin, stdout, stderr|
      stdin.write(wanted_acls)
      stdin.close
      errormsg = stderr.read
      if !errormsg.empty? then
        raise "Could not set ACLs for #{ path }: #{ errormsg }"
      end
    end
  end
end

class ShareBox
  def initialize(admins)
    @admin_groupnames = admins.map { |group| group.cn }
    @shares           = {}
  end

  def add_dir(spec)
    # check directory mode to allow disabling directories (chmod 000)
    begin
      return if (File.stat(spec.sharepath).mode & 0x777) == 0
    rescue Errno::ENOENT
      # if directory does not exist, it is enabled (it should be created)
    end

    spec_with_admin_writes             = spec.clone
    spec_with_admin_writes.writegroups = @admin_groupnames | spec.writegroups

    share = PuavoShare.new(spec_with_admin_writes)

    share.create_sharedir
    @shares[spec.sharepath] = share
  end

  def fix_acls(path)
    @shares.values.each do |share|
      if share.parent_of?(path) then
        # in case of a directory, it might have been moved from another
        # directory, in which case we need to traverse it
        if File.directory?(path) then
          share.fix_acls_recursively(path)
        else
          share.fix_acls(path)
        end
      end
    end
  end

  def fix_acls_recursively
    Syslog.notice('%s', 'Fixing permissions recursively.')
    @shares.values.each do |share|
      share.fix_acls_recursively
    end
  end

  def paths_with_automatic_acls
    @shares.map { |path, share| share.automatic_acls ? path : nil }.compact
  end
end

class PuavoGroup
  def initialize(ldapentry)
    @ldapentry = ldapentry
  end

  def first(attr)
    Array(@ldapentry[attr]).first \
      or raise "Attribute '#{ attr }' is missing from an ldap entry"
  end

  def cn               ; first('cn'               ); end
  def displayName      ; first('displayName'      ); end
  def preferredLanguage; first('preferredLanguage'); end

  def puavoId
    puavoId = first('puavoId')
    raise "PuavoId is not numeric" unless puavoId.match(/^\d+$/)
    puavoId
  end
end

class PuavoShareLDAP
  def initialize
    @puavoldap = PuavoLdap.new
  end

  def find_groups(filter)
    groups = []
    @puavoldap.search_with_baseprefix('ou=Groups', filter) do |entry|
      groups << PuavoGroup.new(entry)
    end
    groups
  end

  def find_groups_by_school(school)
    cn_escaped = @puavoldap.escape(school.cn)
    schoolfilter = "(&(objectClass=puavoSchool)(cn=#{ cn_escaped }))"
    schooldn     = ''
    @puavoldap.search_with_baseprefix('ou=Groups', schoolfilter) do |entry|
      schooldn = entry.dn
    end

    dn_escaped = @puavoldap.escape(schooldn)
    find_groups("(&(objectClass=puavoEduGroup)(puavoSchool=#{ dn_escaped }))")
  end

  def find_schools_served_by_this_server()
    @puavoldap.filter_by_schools_served_by_this_server() \
              .map { |e| PuavoGroup.new(e) }
  end

  def find_teacher_groups()
    groups = find_groups('(objectClass=puavoEduGroup)')
    # XXX we need something smarter here than regexp
    groups.select { |group| group.cn =~ /(-ope|opettajat)/ }
  end
end

class Control
  def initialize_shares_from_ldap
    shareldap = PuavoShareLDAP.new

    @managed_schooldirs = []

    admins   = shareldap.find_teacher_groups()
    @schools = shareldap.find_schools_served_by_this_server()

    @box = ShareBox.new(admins)

    # make the share base directories
    PuavoShare.mkdir(ShareDirBase,          0755)
    PuavoShare.mkdir(ShareDirBaseFileStore, 0755)
    Languages.each do |lang|
      basedir = PuavoSharedDirectories.dirname('base', lang) 
      PuavoShare.mkdir("#{ ShareDirBase }/#{ basedir }", 0755)
    end

    @schools.each do |school|
      add_directory(shareldap, school)
    end
  end

  def add_directory(shareldap, school)
    school_sharedir = "#{ ShareDirBaseFileStore }/#{ school.puavoId }"
    @managed_schooldirs << school_sharedir

    PuavoShare.mkdir(school_sharedir, 0755)

    Syslog.notice('%s',
      "Managing directory for school '#{ school.cn }' (#{ school_sharedir }).")

    ss_tmpl = PuavoShareSpec.new(:automatic_acls  => true,
                                 :group           => nil,
                                 :readgroups      => [],
                                 :school          => school,
                                 :sharepath       => nil,
                                 :writegroups     => [],
                                 :writeonlygroups => [])


    ss = ss_tmpl.clone
    ss.automatic_acls = false
    ss.sharepath = "#{ school_sharedir }/programs"
    @box.add_dir(ss)

    ss = ss_tmpl.clone
    ss.readgroups = [ school.cn ]
    ss.sharepath = "#{ school_sharedir }/material"
    @box.add_dir(ss)

    ss = ss_tmpl.clone
    ss.sharepath = "#{ school_sharedir }/all"
    ss.writegroups = [ school.cn ]
    @box.add_dir(ss)

    school_groups = shareldap.find_groups_by_school(school)

    writeonlygroups = school_groups.map { |group| group.cn }

    ss = ss_tmpl.clone
    ss.sharepath = "#{ school_sharedir }/returnbox"
    ss.writeonlygroups = writeonlygroups
    @box.add_dir(ss)

    ss = ss_tmpl.clone
    ss.sharepath = "#{ school_sharedir }/returns"
    @box.add_dir(ss)

    school_groups.each do |group|
      group_sharedir \
        = "#{ ShareDirBaseFileStore }/#{ school.puavoId }/#{ group.puavoId }"

      ss = ss_tmpl.clone
      ss.group = group
      ss.sharepath = group_sharedir
      ss.writegroups = [ group.cn ]

      @box.add_dir(ss)
    end
  end

  def fix_acls_recursively
    @box.fix_acls_recursively
  end

  def run_notifier(max_seconds)
    max_time     = Time.new.to_i + max_seconds
    seconds_left = max_seconds

    begin
      notifier = INotify::Notifier.new
      begin
        @box.paths_with_automatic_acls.each do |monitor_path|
          events = [
            :create,
            :dont_follow,
            :modify,
            :moved_to,
            :move_self,
            :recursive,
          ]

          notifier.watch(monitor_path, *events) do |event|
            path = event.absolute_name

            # :recursive does not work reliably due to some race condition,
            # so we must help it a bit for freshly created directories
            if File.directory?(path) then
              notifier.watch(path, *events)
            elsif File.dirname(path).end_with?('/returnbox') && event.flags.include?(:create) then
              targetpathbase="#{File.dirname(File.dirname(path))}/returns/#{Etc.getpwuid(
                File.stat(path).uid).name}-#{File.basename(path)}"
              targetpath=targetpathbase
              i=0
              targetpath=targetpathbase+"-#{i+=1}" while File.exist?(targetpath)
              File.link(path, targetpath)
            end

            Syslog.info('%s', "An event occurred on #{ path }, checking ACLs.")
            @box.fix_acls(path)
          end
        end

        while IO.select([ notifier.to_io ], [], [], seconds_left) do
          notifier.process
          seconds_left = [ max_time - Time.new.to_i, 0 ].max
          break if seconds_left == 0
        end
      ensure
        # XXX notifier.close did not work in version 0.7.0-3,
        # XXX hopefully this does the same
        notifier.to_io.close
      end
    rescue StandardError => e
      msg = "Error with inotifier: #{ e.message }: #{ e.backtrace.inspect }." \
            + '  Check out the limit in /proc/sys/fs/inotify/max_user_watches.'
      Syslog.err('%s', msg)
    end
  end

  def self.delete_if_an_empty_dir(path)
    if not path.match(/^#{ ShareDirBase }/) then
      raise "An internal error, trying to delete '#{ path }' (dir)"
    end
    begin
      Dir.rmdir(path)
      Syslog.info('Deleted directory %s as it was empty', path)
    rescue Errno::ENOTEMPTY, Errno::ENOTDIR, Errno::ENOENT
    rescue StandardError => e
      Syslog.warning('Error in removing a possible directory %s', path)
    end
  end

  def delete_unmanaged_paths
    # Delete links that do not point to school directories
    # that are managed by us.
    Find.find(ShareDirBase).each do |path|
      begin
        linkdest = File.readlink(path)
      rescue Errno::EINVAL
        next
      end

      target = Pathname.new(File.join(path, '..', linkdest)).cleanpath.to_s
      next if @managed_schooldirs.any? { |dir| is_parent_of?(target, dir) }

      PuavoShare.delete_link(path)
      Syslog.info('Deleted link %s as it should not be managed by us', path)
    end

    # Delete school directories that we should not be managing.
    Dir.glob(File.join(ShareDirBase, '*', '*')).each do |path|
      self.class.delete_if_an_empty_dir(path)
    end

    # Delete unmanaged empty dirs under /home/share/.share, run twice
    # because the first run may empty a directory we can delete later.
    # Does not remove files!  Link structures to files can be recreated
    # later in case those are needed.
    2.times do
      Find.find(ShareDirBaseFileStore).each do |path|
        Find.prune if @managed_schooldirs.include?(path)
        next if path == ShareDirBaseFileStore
        self.class.delete_if_an_empty_dir(path)
      end
    end
  end

  def run_notifier_but_breakout_at_nighttime
    # Run run_notifier for (25 - Time.new.hour) hours, so we will break out
    # to searching LDAP and fixing all file permissions once a night,
    # somewhere between one and two o'clock.
    Syslog.debug('%s', 'Starting notifier.')
    run_notifier((25 - Time.new.hour) * 60 * 60)
    Syslog.debug('%s', 'Broke out of notifier.')
  end
end

loop do
  Syslog.open(File.basename($0), Syslog::LOG_DAEMON|Syslog::LOG_PID)

  exception_msg = nil
  begin
    control = Control.new
    control.initialize_shares_from_ldap

    notifier_pid = fork { control.run_notifier_but_breakout_at_nighttime }
    control.fix_acls_recursively()
    control.delete_unmanaged_paths()
    Process.waitpid(notifier_pid)

  rescue Net::LDAP::Error => e
    exception_msg = "Error in accessing LDAP: #{ e.message }"
  rescue StandardError => e
    exception_msg = "Unknown error: #{ e.message }"
  end

  if exception_msg then
    Syslog.err('%s', exception_msg)
    sleep 60    # wait a while in case errors just repeat and repeat...
  end

  Syslog.close
end

# tests:
#
# rm -rf 1; \
# for i in $(seq 100); do \
#   FOO=""; \
#   for j in $(seq $i); do \
#     FOO="$FOO$j/"; \
#   done; \
#   DATA=$(getfacl $(dirname $FOO)); \
#   mkdir -p $FOO || { echo -e -n "$DATA\n\n"; getfacl $(dirname $FOO); \
#   break; }; \
# done
