OpenConcerto

Dépôt officiel du code source de l'ERP OpenConcerto
sonarqube

svn://code.openconcerto.org/openconcerto

Rev

Rev 149 | Rev 174 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 * 
 * Copyright 2011 OpenConcerto, by ILM Informatique. All rights reserved.
 * 
 * The contents of this file are subject to the terms of the GNU General Public License Version 3
 * only ("GPL"). You may not use this file except in compliance with the License. You can obtain a
 * copy of the License at http://www.gnu.org/licenses/gpl-3.0.html See the License for the specific
 * language governing permissions and limitations under the License.
 * 
 * When distributing the software, include this License Header Notice in each file.
 */
 
 package org.openconcerto.erp.core.sales.pos.model;

import org.openconcerto.erp.core.sales.pos.POSConfiguration;
import org.openconcerto.erp.core.sales.pos.model.RegisterLog.EventType;
import org.openconcerto.erp.core.sales.pos.model.RegisterLogEntry.ReceiptEntry;
import org.openconcerto.erp.core.sales.pos.model.RegisterState.Status;
import org.openconcerto.utils.CompareUtils;
import org.openconcerto.utils.FileUtils;
import org.openconcerto.utils.MessageDigestUtils;
import org.openconcerto.utils.StringUtils;
import org.openconcerto.utils.TimeUtils;
import org.openconcerto.utils.cc.ExnTransformer;
import org.openconcerto.utils.checks.ValidState;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.DigestInputStream;
import java.security.DigestOutputStream;
import java.sql.SQLException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.regex.Pattern;

import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.jdom2.input.SAXBuilder;
import org.jdom2.output.Format;
import org.jdom2.output.XMLOutputter;

/**
 * <pre>
lockFile
2017
  12
    18/current or if not there previous
       log.xml
       log.xml.hash
       0218121700001.xml
       0218121700001.xml.hash
 * </pre>
 */
public class RegisterFiles {

    private static final String REGISTER_DIRNAME = "register";
    public static final String STRUCT_VERSION_2013 = "v20131206";
    public static final String STRUCT_VERSION = "v20171220";

    private static final String LOG_FILENAME = "log.xml";
    static final String HASH_SUFFIX = ".hash";
    private static final String LOG_HASH_FILENAME = LOG_FILENAME + HASH_SUFFIX;

    static private final Comparator<Path> FILENAME_COMPARATOR = new Comparator<Path>() {
        @Override
        public int compare(Path p1, Path p2) {
            return p1.getFileName().toString().compareTo(p2.getFileName().toString());
        }
    };
    static private final Comparator<Path> PATH_COMPARATOR = new Comparator<Path>() {
        @Override
        public int compare(Path p1, Path p2) {
            return p1.toString().compareTo(p2.toString());
        }
    };

    static private final Path getGreatestSubDir(final Path dir) throws IOException {
        Path res = null;
        if (dir != null && Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
            try (final DirectoryStream<Path> subdirs = Files.newDirectoryStream(dir, FileUtils.DIR_PATH_FILTER)) {
                for (final Path subdir : subdirs) {
                    if (res == null || FILENAME_COMPARATOR.compare(subdir, res) > 0) {
                        res = subdir;
                    }
                }
            }
        }
        return res;
    }

    static private final ValidState canReadFile(final Path f, final String missingString, final String missingPermString) throws IOException {
        if (!Files.isRegularFile(f))
            return ValidState.createCached(false, missingString);
        if (!Files.isReadable(f))
            return ValidState.createCached(false, missingPermString);
        return ValidState.getTrueInstance();
    }

    static public final byte[] save(final Document doc, final Path f) throws IOException {
        final XMLOutputter out = new XMLOutputter(Format.getPrettyFormat());
        final byte[] res;
        try (final DigestOutputStream digestStream = new DigestOutputStream(Files.newOutputStream(f), MessageDigestUtils.getSHA256())) {
            out.output(doc, digestStream);
            res = digestStream.getMessageDigest().digest();
        }
        Files.write(f.resolveSibling(f.getFileName() + HASH_SUFFIX), MessageDigestUtils.asHex(res).getBytes(StringUtils.UTF8));
        return res;
    }

    static public final class HashMode {

        static public final HashMode NOT_REQUIRED = new HashMode(false, null);
        static public final HashMode REQUIRED = new HashMode(true, null);

        static public final HashMode equalTo(final String hashRequired) {
            return new HashMode(true, hashRequired);
        }

        private final boolean hashFileRequired;
        private final String hashRequired;

        private HashMode(boolean hashFileRequired, String hashRequired) {
            super();
            this.hashFileRequired = hashFileRequired;
            this.hashRequired = hashRequired;
        }
    }

    static public final Document parse(final Path f) throws IOException, JDOMException {
        return parse(f, HashMode.REQUIRED);
    }

    static public final Document parse(final Path f, final HashMode hashMode) throws IOException, JDOMException {
        final byte[] hash;
        final Path logHashFile = f.resolveSibling(f.getFileName() + HASH_SUFFIX);
        if (Files.isRegularFile(logHashFile)) {
            final String hashString = Files.readAllLines(logHashFile, StringUtils.UTF8).get(0);
            if (hashMode.hashRequired != null && !hashString.equals(hashMode.hashRequired))
                throw new IllegalStateException("Required hash doesn't match recorded hash");
            hash = MessageDigestUtils.fromHex(hashString);
            assert hash != null;
        } else if (hashMode.hashFileRequired) {
            throw new IllegalStateException("Missing required hash file for " + f);
        } else {
            hash = null;
        }
        final Document doc;
        try (final InputStream ins = new BufferedInputStream(Files.newInputStream(f, LinkOption.NOFOLLOW_LINKS));
                final DigestInputStream dIns = new DigestInputStream(ins, MessageDigestUtils.getSHA256())) {
            doc = new SAXBuilder().build(dIns);
            if (hash != null && !Arrays.equals(hash, dIns.getMessageDigest().digest()))
                throw new IOException("File hash doesn't match recorded hash for " + f);
        }
        return doc;
    }

    static private final Pattern DIGITS_PATTERN = Pattern.compile("[0-9]+");

    static public final List<RegisterFiles> scan(final Path rootDir) throws IOException {
        final Path registersDir = rootDir.resolve(REGISTER_DIRNAME);
        if (!Files.exists(registersDir))
            return Collections.emptyList();
        final List<RegisterFiles> res = new ArrayList<>();
        try (final DirectoryStream<Path> stream = Files.newDirectoryStream(registersDir, new DirectoryStream.Filter<Path>() {
            @Override
            public boolean accept(Path entry) throws IOException {
                return DIGITS_PATTERN.matcher(entry.getFileName().toString()).matches();
            }
        })) {
            for (final Path registerDir : stream) {
                if (Files.isDirectory(registerDir.resolve(STRUCT_VERSION))) {
                    res.add(new RegisterFiles(rootDir, true, Integer.parseInt(registerDir.getFileName().toString())));
                }
            }
        }
        return res;
    }

    // under that year/month/day
    private final Path rootDir;
    private final boolean useHardLinks;
    private final int posID;
    private final ThreadLocal<Boolean> hasLock = new ThreadLocal<Boolean>() {
        protected Boolean initialValue() {
            return Boolean.FALSE;
        };
    };

    public RegisterFiles(final Path rootDir, final boolean useHardLinks, final int caisse) {
        super();
        this.rootDir = rootDir.resolve(REGISTER_DIRNAME).resolve(Integer.toString(caisse));
        this.useHardLinks = useHardLinks;
        this.posID = caisse;
    }

    private final Path getRootDir() {
        return this.rootDir;
    }

    public final Path getVersionDir() {
        return this.getRootDir().resolve(STRUCT_VERSION);
    }

    public final Path getDayDir(final Calendar day, final boolean create) {
        return ReceiptCode.getDayDir(getVersionDir().toFile(), day, create).toPath();
    }

    public final Path getReceiptFile(final ReceiptCode code) throws IOException {
        final Path dayDirToUse = getDayDirToUse(getDayDir(code.getDay(), false));
        return dayDirToUse == null ? null : dayDirToUse.resolve(code.getFileName());
    }

    public final Path getLogFile(final Calendar day) throws IOException {
        return getLogFile(getDayDir(day, false));
    }

    private final Path getLogFile(final Path dayDir) throws IOException {
        final Path dayDirToUse = getDayDirToUse(dayDir);
        return dayDirToUse == null ? null : dayDirToUse.resolve(LOG_FILENAME);
    }

    public final int getPosID() {
        return this.posID;
    }

    public <T, Exn extends Exception> T doWithLock(final ExnTransformer<RegisterFiles, T, Exn> transf) throws IOException, Exn {
        if (this.hasLock.get())
            throw new IllegalStateException("Already locked");
        this.hasLock.set(Boolean.TRUE);
        try {
            return FileUtils.doWithLock(this.getRootDir().resolve("lockFile").toFile(), new ExnTransformer<RandomAccessFile, T, Exn>() {
                @Override
                public T transformChecked(RandomAccessFile input) throws Exn {
                    return transf.transformChecked(RegisterFiles.this);
                }
            });
        } finally {
            this.hasLock.set(Boolean.FALSE);
        }
    }

    public final RegisterLog getLastLog() throws IOException, JDOMException {
        final Path lastLogFile = this.findLastLogFile();
        if (lastLogFile == null)
            return null;
        return new RegisterLog(lastLogFile).parse();
    }

    public final Path findLastLogFile() throws IOException {
        final Path versionDir = this.getVersionDir();

        // first quick search
        final Path yearDir = getGreatestSubDir(versionDir);
        final Path monthDir = getGreatestSubDir(yearDir);
        if (monthDir != null) {
            final SortedSet<Path> sortedDays = new TreeSet<>(Collections.reverseOrder(FILENAME_COMPARATOR));
            try (final DirectoryStream<Path> dayDirs = Files.newDirectoryStream(monthDir, FileUtils.DIR_PATH_FILTER)) {
                for (final Path dayDir : dayDirs) {
                    sortedDays.add(dayDir);
                }
            }
            Path logToUse = getLogToUse(sortedDays);
            if (logToUse != null)
                return logToUse;

            // then walk the whole tree before giving up
            logToUse = getLogToUse(getSortedDays(false));
            if (logToUse != null)
                return logToUse;
        }

        return null;
    }

    private SortedSet<Path> getSortedDays(final boolean chronological) throws IOException {
        final Path versionDir = this.getVersionDir();
        final SortedSet<Path> sortedPaths = new TreeSet<>(chronological ? PATH_COMPARATOR : Collections.reverseOrder(PATH_COMPARATOR));
        if (Files.exists(versionDir)) {
            Files.walkFileTree(versionDir, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                    final Path dateDir = versionDir.relativize(dir);
                    if (dateDir.getNameCount() == 3) {
                        sortedPaths.add(dir);
                        return FileVisitResult.SKIP_SUBTREE;
                    } else {
                        return FileVisitResult.CONTINUE;
                    }
                }
            });
        }
        return sortedPaths;
    }

    private Path getLogToUse(final SortedSet<Path> sortedDays) throws IOException {
        for (final Path dayDir : sortedDays) {
            final Path logFile = getLogFile(dayDir);
            if (logFile != null)
                return logFile;
        }
        return null;
    }

    public final List<Path> findLogFiles() throws IOException {
        final List<Path> res = new ArrayList<>();
        for (final Path dayDir : getSortedDays(true)) {
            final Path logFile = getLogFile(dayDir);
            if (logFile != null)
                res.add(logFile);
        }
        return res;
    }

    private final Path getDayDirToUse(final Path dayDir) throws IOException {
        for (final Path subdir : new Path[] { dayDir.resolve("current"), dayDir.resolve("previous") }) {
            if (Files.exists(subdir)) {
                final ValidState validity = getDayDirValidity(subdir);
                if (validity.isValid())
                    return subdir;
                else
                    throw new IOException("Invalid " + subdir + " : " + validity.getValidationText());
            }
        }
        return null;
    }

    private final ValidState getDayDirValidity(final Path dayDir) throws IOException {
        if (!Files.isDirectory(dayDir))
            return ValidState.createCached(false, "Not a directory");
        final ValidState canReadLog = canReadFile(dayDir.resolve(LOG_FILENAME), "Missing log file", "Unreadable log file");
        if (!canReadLog.isValid())
            return canReadLog;
        final ValidState canReadLogHash = canReadFile(dayDir.resolve(LOG_HASH_FILENAME), "Missing log hash file", "Unreadable log hash file");
        if (!canReadLogHash.isValid())
            return canReadLogHash;
        return ValidState.getTrueInstance();
    }

    public final RegisterLog open(final int userID, final DBState dbState) throws IOException {
        if (!this.hasLock.get())
            throw new IllegalStateException("Not locked");
        return createOpen(userID, null, dbState).transformChecked(this);
    }

    public final RegisterLog open(final int userID, final RegisterDB registerDB) throws IOException {
        return this.doWithLock(createOpen(userID, registerDB, null));
    }

    static private final ExnTransformer<RegisterFiles, RegisterLog, IOException> createOpen(final int userID, final RegisterDB registerDB, final DBState passedDBState) throws IOException {
        // TODO use UpdateDir like save() and close()
        return new ExnTransformer<RegisterFiles, RegisterLog, IOException>() {
            @Override
            public RegisterLog transformChecked(RegisterFiles input) throws IOException {
                POSConfiguration.getLogger().log(Level.FINE, "Begin opening of FS state for register {0}", input.getPosID());
                POSConfiguration.checkRegisterID(input.getPosID(), registerDB.getPosID());
                final RegisterLog lastLog = input.checkStatus(true);
                final String lastLocalHash;
                final Date prevDate;
                if (lastLog == null) {
                    lastLocalHash = null;
                    prevDate = null;
                } else {
                    try {
                        lastLocalHash = lastLog.getLastReceiptHash();
                    } catch (ParseException e) {
                        throw new IOException("Couldn't parse last receipt of log", e);
                    }
                    prevDate = lastLog.getFirstRegisterEvent().getDate();
                    if (lastLocalHash != null && prevDate == null)
                        throw new IOException("There's a receipt, but no previous closure date");
                }

                final DBState dbState;
                if (passedDBState == null) {
                    try {
                        dbState = registerDB.open(lastLocalHash, userID);
                    } catch (SQLException e) {
                        throw new IOException("Couldn't open the register in the DB", e);
                    }
                } else {
                    dbState = passedDBState;
                }
                if (dbState.getRegisterState().getStatus() != Status.OPEN)
                    throw new IllegalArgumentException("DB not open : " + dbState);

                final Calendar cal = dbState.getLastEntry().getDate("DATE");

                // e.g. 2017/12/21/
                final Path dayDir = input.getDayDir(cal, true);
                // e.g. 2017/12/21/current/
                final Path dayDirToUse = input.getDayDirToUse(dayDir);
                if (dayDirToUse != null)
                    throw new IllegalStateException(cal.getTime() + " already open");

                final Path stagingDir = dayDir.resolve("staging");
                final Path currentDir = stagingDir.resolveSibling("current");
                final Path prevDir = stagingDir.resolveSibling("previous");
                FileUtils.rm_R(stagingDir);
                FileUtils.rm_R(currentDir);
                FileUtils.rm_R(prevDir);
                Files.createDirectory(stagingDir);

                final Element rootElem = RegisterLog.createRootElement();
                rootElem.addContent(new RegisterLogEntry.RegisterEntry(EventType.REGISTER_OPENING, cal.getTime(), userID, input.getPosID(), lastLocalHash, prevDate).toXML());
                save(new Document(rootElem), stagingDir.resolve(LOG_FILENAME));

                Files.move(stagingDir, currentDir, StandardCopyOption.ATOMIC_MOVE);

                POSConfiguration.getLogger().log(Level.INFO, "Finished opening of FS state for register {0}", registerDB);

                // TODO parse and validate before moving into place
                try {
                    return new RegisterLog(currentDir.resolve(LOG_FILENAME)).parse();
                } catch (JDOMException e) {
                    throw new IOException("Couldn't parse new log");
                }
            }
        };
    }

    private abstract class UpdateDir<I, T> extends ExnTransformer<RegisterFiles, T, IOException> {

        private final String logMsg;

        public UpdateDir(final String logMsg) {
            this.logMsg = logMsg;
        }

        @Override
        public T transformChecked(RegisterFiles input) throws IOException {
            POSConfiguration.getLogger().log(Level.FINE, "Begin " + this.logMsg + " for register {0}", input.getPosID());
            final RegisterLog lastLog = checkStatus(needsClosed());

            // e.g. 2017/12/21/current/
            final Path toUse = lastLog.getLogFile().getParent();
            final Path stagingDir = toUse.resolveSibling("staging");
            final Path currentDir = stagingDir.resolveSibling("current");
            final Path prevDir = stagingDir.resolveSibling("previous");

            FileUtils.rm_R(stagingDir);
            FileUtils.copyDirectory(toUse, stagingDir, input.useHardLinks, StandardCopyOption.COPY_ATTRIBUTES);

            final I intermediateRes = updateDir(stagingDir, lastLog);

            if (Files.exists(currentDir)) {
                FileUtils.rm_R(prevDir);
                Files.move(currentDir, prevDir, StandardCopyOption.ATOMIC_MOVE);
            }
            assert !Files.exists(currentDir);

            Files.move(stagingDir, currentDir, StandardCopyOption.ATOMIC_MOVE);

            POSConfiguration.getLogger().log(Level.INFO, "Finished " + this.logMsg + " for register {0}", input.getPosID());

            assert Files.isDirectory(currentDir);
            try {
                FileUtils.rm_R(prevDir);
            } catch (Exception e) {
                // OK to leave behind some small files
                e.printStackTrace();
            }

            return createResult(currentDir, intermediateRes);
        }

        protected abstract boolean needsClosed();

        protected abstract I updateDir(final Path stagingDir, final RegisterLog lastLog) throws IOException;

        protected abstract T createResult(final Path currentDir, final I intermediateRes) throws IOException;

    }

    private final RegisterLog checkStatus(final boolean needsClosed) throws IOException {
        final RegisterLog lastLog;
        try {
            lastLog = getLastLog();
            final boolean closed = lastLog == null || lastLog.getLastRegisterEvent().getType() != EventType.REGISTER_OPENING;
            if (closed != needsClosed)
                throw new IllegalStateException(needsClosed ? "Not closed" : "Not open");
        } catch (JDOMException | ParseException e) {
            throw new IOException(e);
        }
        return lastLog;
    }

    public final RegisterLog close(final int userID) throws IOException {
        return this.doWithLock(new UpdateDir<Object, RegisterLog>("closure of FS state") {
            @Override
            protected boolean needsClosed() {
                return false;
            }

            @Override
            protected Object updateDir(final Path stagingDir, final RegisterLog lastLog) throws IOException {
                final Date now = new Date();
                final String lastHash;
                try {
                    lastHash = lastLog.getLastReceiptHash();
                    // Closure cannot be done (or undone) manually (opening is just a row in
                    // CAISSE_JOURNAL) so check as much as possible
                    final RegisterLogEntry lastEvent = lastLog.getLastEvent();
                    if (isNotChronological(lastEvent.getDate(), now))
                        throw new IllegalStateException("Previous event date is in the future : " + lastEvent);
                } catch (ParseException e) {
                    throw new IOException("Couldn't find last receipt hash", e);
                }
                // TODO verify that receipts' files match the log's content
                final Document doc = lastLog.getDocument().clone();
                doc.getRootElement().addContent(new RegisterLogEntry.RegisterEntry(EventType.REGISTER_CLOSURE, now, userID, getPosID(), lastHash, null).toXML());
                save(doc, stagingDir.resolve(LOG_FILENAME));
                return null;
            }

            @Override
            protected RegisterLog createResult(final Path currentDir, final Object intermediateRes) throws IOException {
                // TODO parse and validate before moving into place
                try {
                    return new RegisterLog(currentDir.resolve(LOG_FILENAME)).parse();
                } catch (JDOMException e) {
                    throw new IOException("Couldn't parse new log");
                }
            }
        });
    }

    public static final class DifferentDayException extends IllegalStateException {
        protected DifferentDayException(final RegisterLog lastLog) {
            super("Cannot save a receipt for a different day than the register opening : " + lastLog.getFirstRegisterEvent());
        }
    }

    public final String save(final Ticket t) throws IOException, SQLException {
        return this.doWithLock(new UpdateDir<String, String>("saving receipt") {
            @Override
            protected boolean needsClosed() {
                return false;
            }

            @Override
            protected String updateDir(final Path stagingDir, final RegisterLog lastLog) throws IOException {
                if (!TimeUtils.isSameDay(t.getCreationCal(), lastLog.getFirstRegisterEvent().getDate()))
                    throw new DifferentDayException(lastLog);
                try {
                    final ReceiptEntry lastReceipt = lastLog.getLastReceiptCreationEvent();
                    final int expectedIndex;
                    final String expectedHash = lastLog.getLastReceiptHash();
                    if (lastReceipt == null) {
                        expectedIndex = 1;
                    } else {
                        expectedIndex = lastReceipt.getCode().getDayIndex() + 1;
                    }
                    if (t.getReceiptCode().getDayIndex() != expectedIndex)
                        throw new IllegalStateException("Non consecutive number");
                    if (!CompareUtils.equals(expectedHash, t.getPreviousHash()))
                        throw new IllegalStateException("Previous hash mismatch, expected " + expectedHash + " but previous of receipt was " + t.getPreviousHash());
                    final RegisterLogEntry lastEvent = lastLog.getLastEvent();
                    if (isNotChronological(lastEvent.getDate(), t.getCreationDate()))
                        throw new IllegalStateException("Previous event (" + lastEvent + ") is after the receipt : " + t.getCreationDate());
                } catch (ParseException e) {
                    throw new IOException("Couldn't parse last receipt of log", e);
                }
                // save receipt
                final String fileHash = MessageDigestUtils.asHex(t.saveToFile(stagingDir.resolve(t.getReceiptCode().getFileName())));
                // update log
                final Document doc = lastLog.getDocument().clone();
                doc.getRootElement().addContent(new RegisterLogEntry.ReceiptEntry(t.getCreationDate(), t.getReceiptCode(), fileHash).toXML());
                save(doc, stagingDir.resolve(LOG_FILENAME));
                return fileHash;
            }

            @Override
            protected String createResult(final Path currentDir, final String intermediateRes) {
                return intermediateRes;
            }
        });
    }

    static final boolean isNotChronological(final Date d1, final Date d2) {
        // allow equal dates to support programmatic creation (faster than millisecond)
        return d1.after(d2);
    }
}