// Go Ldap Api 
// Copyright (c) 2022 yo000 <johan@nosd.in>
//

package main

import (
	"os"
	"fmt"
	"flag"
	"time"
	"errors"
	"strings"
	"net/http"
	"encoding/json"

	"github.com/spf13/viper"
	"github.com/gin-gonic/gin"
	"github.com/go-ldap/ldap/v3"
	log "github.com/sirupsen/logrus"
	//"github.com/gin-gonic/gin/render"
)

var (
	gVersion = "0.5.6"
	
	gRoLdap *MyLdap
)

func marshalResultToText(res *ldap.SearchResult, delimiter string, showValueName, showDN bool) string {
	var txtRes string
	for _, e := range res.Entries {
		if showDN {
			if showValueName {
				txtRes = fmt.Sprintf("%s%s\n", txtRes, e.DN)
			} else {
				txtRes = fmt.Sprintf("%sdn%s%s\n", txtRes, delimiter, e.DN)
			}
		}
		for _, a := range e.Attributes {
			for _, v := range a.Values {
				if showValueName {
					txtRes = fmt.Sprintf("%s%s\n", txtRes, v)
				} else {
					txtRes = fmt.Sprintf("%s%s%s%s\n", txtRes, a.Name, delimiter, v)
				}
			}
		}
		// No DN = No linefeed between entries
		if showDN {
			txtRes = fmt.Sprintf("%s\n", txtRes)
		}
	}
	
	return txtRes
}

func sendResponse(c *gin.Context, res *ldap.SearchResult, format string) {
	// 404 Not found
	if len(res.Entries) == 0 {
		if strings.EqualFold(format, "json") {
			c.JSON(http.StatusNotFound, gin.H{"error": "No result"})
		} else {
			c.String(http.StatusNotFound, "No result")
		}
		return
	}

	log.Debugf("Got %d results", len(res.Entries))

	if strings.EqualFold(format, "json") {
		jsonRes, err := json.Marshal(res.Entries)
		if err != nil {
			log.Errorf("Error marshalling result to json: %v", err)
		}
		log.Debugf("%v\n", string(jsonRes))
		c.String(http.StatusOK, string(jsonRes))

	} else if strings.EqualFold(format, "text") {
		txtRes := marshalResultToText(res, "=", false, true)
		log.Debugf("%v\n", string(txtRes))
		c.String(http.StatusOK, string(txtRes))

	} else if strings.EqualFold(format, "ldif") {
		txtRes := marshalResultToText(res, ": ", false, true)
		log.Debugf("%v\n", string(txtRes))
		c.String(http.StatusOK, string(txtRes))

	} else if strings.EqualFold(format, "textvalue") {
		txtRes := marshalResultToText(res, "", true, true)
		log.Debugf("%v\n", string(txtRes))
		c.String(http.StatusOK, string(txtRes))

	} else if strings.EqualFold(format, "textvalue-nodn") {
		txtRes := marshalResultToText(res, "", true, false)
		log.Debugf("%v\n", string(txtRes))
		c.String(http.StatusOK, string(txtRes))
	}
}

func checkIfModifiedSince(c *gin.Context, myldap *MyLdap, baseDn, cn, class, attributes string) (bool, error) {
	
	// FIXME: We need to cache the last result, because if an item is deleted from LDAP we won't see it and 
	//  we will return 304. So deletions will never make their way to Rspamd
	// For now, lets always return "Modified"
	return true, nil
	
	if len(c.Request.Header["If-Modified-Since"]) > 0 {
		t := strings.Replace(c.Request.Header["If-Modified-Since"][0], "GMT", "+0000", 1)
		ifModifiedSince, _ := time.Parse(time.RFC1123Z, t)
		
		log.Debugf("ifModifiedSince: %s", ifModifiedSince)
		
		res, err := searchByCn(myldap, baseDn, cn, class, "modifyTimestamp")
		if err != nil {
			log.Errorf("Error searching modifyTimestamp for %s in %s : %v", cn, baseDn, err)
			return true, err
		}
		
		// modifyTimestamp format
		mtFmt := "20060102150405Z"
		// Compare each object timestamp
		hasNewer := false
		for _, e := range res.Entries {
			for _, a := range e.Attributes {
				if strings.EqualFold(a.Name, "modifyTimestamp") {
					mt, _ := time.Parse(mtFmt, a.Values[0])
					log.Debugf("%s modifyTimestamp: %s", e.DN, mt)
					if mt.Unix() > ifModifiedSince.Unix() {
						log.Debugf("%s is newer than %s: %s", e.DN, ifModifiedSince, mt)
						hasNewer = true
						break
					}
				}
			}
			if hasNewer {
				break
			}
		}
		
		if false == hasNewer {
			return false, nil
		}
	}
	return true, nil
}

// Basic Authentication handler with local hardcoded account - do not use
func basicAuth(c *gin.Context) {
	user, password, hasAuth := c.Request.BasicAuth()
	if hasAuth && user == "admin" && password == "admin" {
		log.Infof("[%s]: User %s successfully authenticated", c.Request.RemoteAddr, user)
	} else {
		c.AbortWithStatus(http.StatusUnauthorized)
		c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted")
		return
	}
}

// Basic Authentication handler to ldap
func ldapBasicAuth(c *gin.Context) {
	var err error
	// Get user & password from http client
	user, password, hasAuth := c.Request.BasicAuth()
	
	if hasAuth {
		// First find the full DN for provided username
		if gRoLdap.Conn == nil {
			gRoLdap, err = connectLdap(gRoLdap)
			if err != nil {
				log.Errorf("[%s]: Cannot connect to LDAP: %v", c.Request.RemoteAddr, err)
				c.AbortWithStatus(http.StatusUnauthorized)
				c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted")
				return
			}
		}
		
		userDn, err := findUserFullDN(gRoLdap, user)
		if err != nil {
			log.Errorf("[%s]: Cannot connect to LDAP: %v", c.Request.RemoteAddr, err)
			c.AbortWithStatus(http.StatusUnauthorized)
			c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted")
			return
		}
		
		// Then open this ldap connection bound to client credentials, using found full Dn
		cl, err := connectLdap(&MyLdap{Host: gRoLdap.Host, User: userDn, Pass: password, BaseDN: gRoLdap.BaseDN})
		if err != nil {
			log.Errorf("[%s]: Cannot connect to LDAP: %v", c.Request.RemoteAddr, err)
			c.AbortWithStatus(http.StatusUnauthorized)
			c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted")
			return
		} else {
			log.Infof("[%s]: User %s successfully authenticated with bind DN %s", c.Request.RemoteAddr, user, userDn)
			// Store LDAP connection into gin context
			c.Set("ldapCon", cl)
		}
	} else {
		c.AbortWithStatus(http.StatusUnauthorized)
		c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted")
		return
	}
}

func getLdapConFromContext(c *gin.Context) (*MyLdap, error) {
	ldapCon, exist := c.Get("ldapCon")
	if exist != true {
		return nil, errors.New("Cannot get connection from context")
	}
	return ldapCon.(*MyLdap), nil
}

func initRouter(r *gin.Engine) {
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
		return
	})

	// All following routes need authentication
	/* panic: ':ou' in new path '/:ou/:cn/:class' conflicts with existing wildcard ':dn' in existing prefix '/:dn'
	r.GET("/:dn", ldapBasicAuth, func(c *gin.Context) {
		dn := c.Param("dn")
		
		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			log.Error(err)
			c.AbortWithError(http.StatusInternalServerError, nil)
			return
		}
		
		// json format is the default
		format := c.DefaultQuery("format", "json")
			
		res, err := searchByDn(ldapCon, dn, "ALL")
		
		// If DN does not exist, we'll get err='LDAP Result Code 32 "No Such Object"'
		if err != nil {
			if strings.Contains(err.Error(), "LDAP Result Code 32") {
				c.AbortWithError(http.StatusNotFound, err)
				return
			} else {
				log.Errorf("Error searching %s: %v", dn, err)
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}
		}
		sendResponse(c, res, format)
		return
	})*/
	
	r.GET("/:ou/:cn/:class", ldapBasicAuth, func(c *gin.Context) {
		ou := c.Param("ou")
		cn := c.Param("cn")
		class := c.Param("class")
		
		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			log.Error(err)
			c.AbortWithError(http.StatusInternalServerError, nil)
			return
		}

		// json format is the default
		format := c.DefaultQuery("format", "json")

		res, err := searchByCn(ldapCon, ou, cn, class, "ALL")
		
		// If OU does not exist, we'll get err='LDAP Result Code 32 "No Such Object"'
		if err != nil {
			if strings.Contains(err.Error(), "LDAP Result Code 32") {
				c.AbortWithError(http.StatusNotFound, err)
				return
			} else {
				log.Errorf("Error searching %s in %s : %v", cn, ou, err)
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}
		}
		sendResponse(c, res, format)
		return
	})
	
	r.HEAD("/:ou/:cn/:class", ldapBasicAuth, func(c *gin.Context) {
		ou := c.Param("ou")
		cn := c.Param("cn")
		class := c.Param("class")
		
		format := c.DefaultQuery("format", "json")

		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			log.Error(err)
			c.AbortWithError(http.StatusInternalServerError, nil)
			return
		}
		
		modified, err := checkIfModifiedSince(c, ldapCon, ou, cn, class, "ALL")
		if err != nil {
			c.AbortWithError(http.StatusInternalServerError, err)
			return
		}

		if modified {
			res, err := searchByCn(ldapCon, ou, cn, class, "ALL")
			if err != nil {
				log.Errorf("Error searching %s in %s : %v", cn, ou, err)
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}
			sendResponse(c, res, format)
		} else {
			c.String(http.StatusNotModified, "")
		}
		return
	})

	r.GET("/:ou/:cn/:class/:attribute", ldapBasicAuth, func(c *gin.Context) {
		ou := c.Param("ou")
		cn := c.Param("cn")
		attr := c.Param("attribute")
		class := c.Param("class")

		format := c.DefaultQuery("format", "json")
		
		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			log.Error(err)
			c.AbortWithError(http.StatusInternalServerError, nil)
			return
		}

		res, err := searchByCn(ldapCon, ou, cn, class, attr)
		if err != nil {
			if strings.Contains(err.Error(), "LDAP Result Code 32") {
				c.AbortWithError(http.StatusNotFound, err)
				return
			} else {
				log.Errorf("Error searching %s in %s : %v", cn, ou, err)
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}
		}
		sendResponse(c, res, format)
		return
	})
	
	r.HEAD("/:ou/:cn/:class/:attribute", ldapBasicAuth, func(c *gin.Context) {
		ou := c.Param("ou")
		cn := c.Param("cn")
		attr := c.Param("attribute")
		class := c.Param("class")

		format := c.DefaultQuery("format", "json")

		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			log.Error(err)
			c.AbortWithError(http.StatusInternalServerError, nil)
			return
		}

		modified, err := checkIfModifiedSince(c, ldapCon, ou, cn, class, attr)
		if err != nil {
			c.AbortWithError(http.StatusInternalServerError, err)
			return
		}
		
		if modified {
			res, err := searchByCn(ldapCon, ou, cn, class, attr)
			if err != nil {
				log.Errorf("Error searching %s in %s : %v", cn, ou, err)
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}
			sendResponse(c, res, format)
		} else {
			c.String(http.StatusNotModified, "")
		}
		return
	})
	
	/* 2 call methods : Either DN in url, or DN in body using /add :
	 *  *  curl -u "admin:admin" -H "Content-Type: application/json" -X POST 
	 *  --data '{"objectClass":["person","top"],"cn":"newuser","sn":"New"}' \
	 *  https://localhost:8443/cn=newuser,ou=users,dc=example,dc=org
	 * 
	 *  curl -u "admin:admin" -H "Content-Type: application/json" -X POST 
	 *  --data '{"dn":"cn=newuser,ou=users,dc=example,dc=org","objectClass":["person","top"],"cn":"newuser","sn":"New"}' \
	 *  https://localhost:8443/add
	 */	
	r.POST("/:dn", ldapBasicAuth, func(c *gin.Context) {
		dn := c.Param("dn")

		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			//log.Error(err)
			c.AbortWithError(http.StatusInternalServerError, err)
			return
		}
		
		// Unmarshall json body to a map
		if c.Request.Header.Get("Content-Type") == "application/json" {
			var attributes map[string]interface{}
			err := c.ShouldBindJSON(&attributes)
			if err != nil {
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}

			// Get dn in body if called with "http://1.2.3.4/add"
			if strings.EqualFold(dn, "add") {
				dn = attributes["dn"].(string)
			}
			if len(dn) == 0 {
				c.AbortWithError(http.StatusBadRequest, err)
				return
			}

			err = createEntry(ldapCon, dn, attributes)
			if err != nil {
				if strings.Contains(err.Error(), "LDAP Result Code 50") {
					c.AbortWithStatus(http.StatusUnauthorized)
					return
				// "Entry Already Exists"
				} else if strings.Contains(err.Error(), "LDAP Result Code 68") {
					c.JSON(http.StatusCreated, gin.H{"message": "Entry already exists"})
					/* This returns 201/Created with Location header, although 303/SeeOther is specified
					 * c.Render(http.StatusSeeOther, render.Redirect{
						Code: 303,
						Location: fmt.Sprintf("http://1.2.3.4/%s", dn),
						Request: c.Request,
					})*/
					return
				} else {
					c.AbortWithError(http.StatusBadRequest, err)
					return
				}
			}
			
			c.JSON(http.StatusCreated, gin.H{"message": "Successfully created"})
		}
	})
	
	/*
	 *	curl -u "admin:admin" --header "Content-Type: application/json" -X PUT 
	 *  --data '{"objectClass":["person","top"],"cn":"newuser","sn":"New","description":"Test account"}' \
	 *  https://localhost:8443/cn=newuser,ou=users,dc=example,dc=org
	 * or
	 * curl -u "admin:admin" -H "Content-Type: application/json" -X PUT 
	 *  -d '{"dn":"cn=newuser,ou=users,dc=example,dc=org", \
	 * "objectClass":["person","top"],"cn":"newuser","sn":"New","description":"Test account"}' \
	 *  https://localhost:8443/modify
	 */	
	r.PUT("/:dn", ldapBasicAuth, func(c *gin.Context) {
		dn := c.Param("dn")
		
		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			c.AbortWithError(http.StatusInternalServerError, err)
			return
		}
		
		// Unmarshall json body to a map
		if c.Request.Header.Get("Content-Type") == "application/json" {
			var attributes map[string]interface{}
			err := c.ShouldBindJSON(&attributes)
			if err != nil {
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}
			
			// Get dn in body if called with "http://1.2.3.4/modify"
			if strings.EqualFold(dn, "modify") {
				dn = attributes["dn"].(string)
			}
			if len(dn) == 0 {
				c.AbortWithError(http.StatusBadRequest, err)
				return
			}
			
			err = updateEntry(ldapCon, dn, attributes)
			if err != nil {
				if strings.Contains(err.Error(), "LDAP Result Code 50") {
					c.AbortWithStatus(http.StatusUnauthorized)
					return
				} else if strings.Contains(err.Error(), "No modification required") {
					c.JSON(http.StatusNoContent, gin.H{"message": "No modification required"})
					return
				} else {
					c.AbortWithError(http.StatusBadRequest, err)
					return
				}
			}
			
			c.JSON(http.StatusCreated, gin.H{"message": "Successfully updated"})
		}
	})
	
	/* 2 call methods : Either DN in url, or DN in body using /delete :
	 * curl -i -u "admin:admin" -X DELETE https://localhost:8443/cn=newuser,ou=users,dc=example,dc=org
	 * or
	 * curl -i -u "admin:admin" -X DELETE -H "Content-Type: application/json" -d '{"dn":"cn=newuser,ou=users,dc=example,dc=org"}' https://localhost:8443/delete
	 * 
	 * Each leaf have to be deleted (cannot delete if subordinates)
	 */
	r.DELETE("/:dn", ldapBasicAuth, func(c *gin.Context) {
		dn := c.Param("dn")
		
		// Get user authenticated LDAP connection from context
		ldapCon, err := getLdapConFromContext(c)
		if err != nil {
			c.AbortWithError(http.StatusInternalServerError, err)
			return
		}
		
		// Unmarshall json body to a map
		if c.Request.Header.Get("Content-Type") == "application/json" {
			var attributes map[string]interface{}
			err := c.ShouldBindJSON(&attributes)
			if err != nil {
				c.AbortWithError(http.StatusInternalServerError, err)
				return
			}
			
			// Get dn in body if called with "http://1.2.3.4/delete"
			if strings.EqualFold(dn, "delete") {
				dn = attributes["dn"].(string)
			}
			if len(dn) == 0 {
				c.AbortWithError(http.StatusBadRequest, err)
				return
			}
		
			err = deleteEntry(ldapCon, dn)
			if err != nil {
				//log.Errorf("Error creating %s: %v", dn, err)
				c.AbortWithError(http.StatusBadRequest, err)
				return
			}
		
			c.JSON(http.StatusOK, gin.H{"message": "Successfully deleted"})
		} else {
			err = deleteEntry(ldapCon, dn)
			if err != nil {
				c.AbortWithError(http.StatusBadRequest, err)
				return
			}
		}
	})
}

func main() {
	var confFile string
	var listen string
	var ldapHost string
	var ldapUser string
	var ldapPass string
	var ldapBaseDN string
	var ldapAuthBaseDN string
	var tlsPrivKey string
	var tlsCert string
	var doTls bool
	var debug bool

	flag.StringVar(&confFile, "config", "", "Path to the config file (optional)")
	flag.StringVar(&listen, "listen-addr", "0.0.0.0:8080", "listen address for server")
	flag.StringVar(&ldapHost, "ldap-host", "", "ldap host to connect to")
	flag.StringVar(&ldapUser, "ldap-user", "", "ldap read-only username")
	flag.StringVar(&ldapPass, "ldap-pass", "", "ldap password")
	flag.StringVar(&ldapBaseDN, "ldap-base-dn", "", "ldap base DN")
	flag.StringVar(&ldapAuthBaseDN, "ldap-auth-base-dn", "", "ldap base DN to find authenticating users")
	flag.BoolVar(&doTls, "https", false, "Serve over TLS")
	flag.StringVar(&tlsPrivKey, "ssl-private-key", "", "SSL Private key")
	flag.StringVar(&tlsCert, "ssl-certificate", "", "SSL certificate (PEM format)")
	flag.BoolVar(&debug, "debug", false, "Set log level to debug")
	
	flag.Parse()

	if len(confFile) > 0 {
		viper.SetConfigFile(confFile)
		if err := viper.ReadInConfig(); err != nil {
			log.Fatalf("Could not open config file: %v", err)
			os.Exit(1)
		}
	}
	
	if false == debug {
		debug = viper.GetBool("DEBUG")
	}

	if strings.EqualFold(listen, "0.0.0.0:8080") && len(confFile) > 0 {
		l := viper.GetString("LISTEN")
		if len(l) > 0 {
			listen = l
		}
	}

	if len(ldapHost) == 0 {
		l := viper.GetString("LDAP_HOST")
		if len(l) > 0 {
			ldapHost = l
		} else {
			log.Fatal("No ldap-host defined!")
		}
	}
	if len(ldapUser) == 0 {
		l := viper.GetString("LDAP_USER")
		if len(l) > 0 {
			ldapUser = l
		} else {
			log.Fatal("No ldap-user defined!")
		}
	}
	if len(ldapPass) == 0 {
		l := viper.GetString("LDAP_PASS")
		if len(l) > 0 {
			ldapPass = l
		} else {
			log.Fatal("No ldap-pass defined!")
		}
	}
	if len(ldapBaseDN) == 0 {
		l := viper.GetString("LDAP_BASE_DN")
		if len(l) > 0 {
			ldapBaseDN = l
		} else {
			log.Fatal("No ldap-base-dn defined!")
		}
	}
	if len(ldapAuthBaseDN) == 0 {
		l := viper.GetString("LDAP_AUTH_BASE_DN")
		if len(l) > 0 {
			ldapAuthBaseDN = l
		} else {
			log.Fatal("No ldap-auth-base-dn defined!")
		}
	}
	if false == doTls {
		doTls = viper.GetBool("HTTPS")
	}
	if doTls && len(tlsCert) == 0 {
		l := viper.GetString("SSL_CERTIFICATE")
		if len(l) > 0 {
			tlsCert = l
		} else {
			log.Fatal("SSL certificate must be set to use https!")
		}
	}
	if doTls && len(tlsPrivKey) == 0 {
		l := viper.GetString("SSL_PRIVATE_KEY")
		if len(l) > 0 {
			tlsPrivKey = l
		} else {
			log.Fatal("SSL private key must be set to use https!")
		}
	}

	log.Println("Starting Go Ldap API v.", gVersion)
	if debug {
		log.SetLevel(log.DebugLevel)
	}

	r := gin.Default()

	gRoLdap = &MyLdap{Host: ldapHost, User: ldapUser, Pass: ldapPass, BaseDN: ldapBaseDN, AuthBaseDN: ldapAuthBaseDN}
	_, err := connectLdap(gRoLdap)
	if err != nil {
		log.Fatalf("Cannot connect to ldap: %v", err)
	}
	
	
	initRouter(r)

	if doTls {
		r.RunTLS(listen, tlsCert, tlsPrivKey)
	} else {
		r.Run(listen)
	}
}


