// Collects email reminders set by users for special occasions and moves
// them to the email-reminder spool directory.
//
// Email-reminder allows users to define events that they want to be
// reminded of by email.
//
// This program is meant to be invoked every day by a cron job or as the
// root user.  It collects the reminder files from each user.
//
// # Copyright (C) 2023-2025 Francois Marier
//
// Email-Reminder 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 3 of the
// License, or (at your option) any later version.
//
// Email-Reminder 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 Email-Reminder; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
// 02110-1301, USA.
package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"os"
	"os/user"
	"path/filepath"
	"slices"
	"strconv"
	"strings"

	"launchpad.net/email-reminder/internal/config"
	"launchpad.net/email-reminder/internal/util"
)

const etcPasswd = "/etc/passwd"
const (
	FieldUsername = iota
	FieldPassword
	FieldUid
	FieldGid
	FieldGECOS
	FieldHomeDir
	FieldShell
)

var nonInteractiveShells = []string{"/bin/false", "/sbin/nologin", "/usr/bin/git-shell", "/usr/sbin/nologin"}

type HomeDir struct {
	Directory string
	Uid       int
}

func copyReminders(homeDir HomeDir, conf config.Config) {
	configFilename := config.GetUserConfigFilename(homeDir.Directory)
	if configFilename == "" {
		if conf.Verbose {
			log.Printf("No reminders in '%s'\n", homeDir.Directory)
		}
		return
	}

	// OpenInRoot prevents symlink escapes outside homeDir.Directory
	src, err := os.OpenInRoot(homeDir.Directory, configFilename)
	if err != nil {
		configFilePath := filepath.Join(homeDir.Directory, configFilename)
		log.Printf("Could not read from '%s': %s\n", configFilePath, err)
		return
	}
	defer src.Close()

	// Validate ownership and file type on the *open* file descriptor to avoid TOCTOU races
	if err := util.ValidateOpenFile(src, uint32(homeDir.Uid)); err != nil {
		log.Printf("Skipping config file '%s': %s\n", src.Name(), err)
		return
	}

	destinationFilePath := filepath.Join(config.SpoolDir, strconv.Itoa(homeDir.Uid))
	if conf.Simulate {
		log.Printf("Simulating, not actually copying '%s' to '%s'\n", src.Name(), destinationFilePath)
		return
	}

	dst, err := os.Create(destinationFilePath)
	if err != nil {
		log.Printf("Could not write to '%s': %s\n", destinationFilePath, err)
		return
	}
	defer dst.Close()

	// Set the reader limit higher than the max file size to detect files that are too large
	written, err := io.Copy(dst, io.LimitReader(src, int64(conf.MaxXMLSizeBytes)+1))
	if err != nil {
		log.Printf("Could not copy '%s' to '%s': %s\n", src.Name(), dst.Name(), err)
		os.Remove(destinationFilePath)
		return
	}
	if written > int64(conf.MaxXMLSizeBytes) {
		log.Printf("Skipping config file '%s': XML file too large (max %d bytes)\n", src.Name(), conf.MaxXMLSizeBytes)
		os.Remove(destinationFilePath)
		return
	}
	if conf.Verbose {
		log.Printf("Copied '%s' to '%s'\n", src.Name(), dst.Name())
	}
}

func localHomeDirs(passwd string, verbose bool) []HomeDir {
	homeDirs := make([]HomeDir, 0, 2)
	data, err := os.ReadFile(passwd)
	if err != nil {
		log.Printf("Could not open %s: %s\n", etcPasswd, err)
		return homeDirs
	}
	for _, line := range strings.Split(string(data), "\n") {
		fields := strings.Split(line, ":")
		if len(fields) == FieldShell+1 {
			uid, err := strconv.Atoi(fields[FieldUid])
			if err != nil {
				if verbose {
					log.Printf("Skipping invalid user %s with invalid UID %s\n", fields[FieldUsername], fields[FieldUid])
				}
				continue
			}
			if uid < 1000 || uid >= 60000 || slices.Contains(nonInteractiveShells, fields[FieldShell]) {
				// This is likely a system user account, not a normal user account.
				continue
			}
			if verbose {
				log.Printf("Found %s (%d:%s) with homedir '%s'\n", fields[FieldUsername], uid, fields[FieldShell], fields[FieldHomeDir])
			}

			if err := util.ValidateHomeDirectory(fields[FieldHomeDir], uint32(uid)); err != nil {
				if verbose {
					log.Printf("Skipping user %s: %s\n", fields[FieldUsername], err)
				}
				continue
			}

			homeDirs = append(homeDirs, HomeDir{fields[FieldHomeDir], uid})
		}
	}
	return homeDirs
}

func requireRoot(verbose bool) {
	currentUser, err := user.Current()
	if err != nil {
		log.Fatalln(err)
	}

	if currentUser.Uid != "0" {
		if verbose {
			log.Printf("Current user is %s (%s)\n", currentUser.Username, currentUser.Uid)
		}
		log.Fatalln("This script must be run as root.")
	}
}

func main() {
	log.SetFlags(0)

	simulate := flag.Bool("simulate", false, "do not actually copy any files")
	verbose := flag.Bool("verbose", false, "print out information about what the program is doing")
	version := flag.Bool("version", false, "display the version number")
	// TODO: add short names too, either hacking it like in https://www.antoniojgutierrez.com/posts/2021-05-14-short-and-long-options-in-go-flags-pkg/
	// or using another package like golang-github-pborman-getopt-dev in Debian
	flag.Parse()
	if *version {
		fmt.Println("collect-reminders " + config.VersionNumber)
		os.Exit(0)
	}

	requireRoot(*verbose)
	conf := config.ReadSystemWideConfig(*simulate, *verbose)

	homeDirs := localHomeDirs(etcPasswd, *verbose)
	if *verbose {
		log.Printf("Found these local home directories: %v\n", homeDirs)
	}
	for _, homeDir := range homeDirs {
		copyReminders(homeDir, conf)
	}
}
