All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.notedb.ChangeNoteUtil Maven / Gradle / Ivy

// Copyright (C) 2013 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.server.notedb;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ListMultimap;
import com.google.common.primitives.Ints;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.Comment;
import com.google.gerrit.reviewdb.client.CommentRange;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.inject.Inject;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.sql.Timestamp;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.FooterKey;
import org.eclipse.jgit.util.GitDateFormatter;
import org.eclipse.jgit.util.GitDateFormatter.Format;
import org.eclipse.jgit.util.GitDateParser;
import org.eclipse.jgit.util.MutableInteger;
import org.eclipse.jgit.util.QuotedString;
import org.eclipse.jgit.util.RawParseUtils;

public class ChangeNoteUtil {
  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
      new FooterKey("Patch-set-description");
  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
  public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");

  private static final String AUTHOR = "Author";
  private static final String BASE_PATCH_SET = "Base-for-patch-set";
  private static final String COMMENT_RANGE = "Comment-range";
  private static final String FILE = "File";
  private static final String LENGTH = "Bytes";
  private static final String PARENT = "Parent";
  private static final String PARENT_NUMBER = "Parent-number";
  private static final String PATCH_SET = "Patch-set";
  private static final String REAL_AUTHOR = "Real-author";
  private static final String REVISION = "Revision";
  private static final String UUID = "UUID";
  private static final String UNRESOLVED = "Unresolved";
  private static final String TAG = FOOTER_TAG.getName();

  public static String formatTime(PersonIdent ident, Timestamp t) {
    GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT);
    // TODO(dborowitz): Use a ThreadLocal or use Joda.
    PersonIdent newIdent = new PersonIdent(ident, t);
    return dateFormatter.formatDate(newIdent);
  }

  static Gson newGson() {
    return new GsonBuilder()
        .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
        .setPrettyPrinting()
        .create();
  }

  private final AccountCache accountCache;
  private final PersonIdent serverIdent;
  private final String anonymousCowardName;
  private final String serverId;
  private final Gson gson = newGson();
  private final boolean writeJson;

  @Inject
  public ChangeNoteUtil(
      AccountCache accountCache,
      @GerritPersonIdent PersonIdent serverIdent,
      @AnonymousCowardName String anonymousCowardName,
      @GerritServerId String serverId,
      @GerritServerConfig Config config) {
    this.accountCache = accountCache;
    this.serverIdent = serverIdent;
    this.anonymousCowardName = anonymousCowardName;
    this.serverId = serverId;
    this.writeJson = config.getBoolean("notedb", "writeJson", true);
  }

  @VisibleForTesting
  public PersonIdent newIdent(
      Account author, Date when, PersonIdent serverIdent, String anonymousCowardName) {
    return new PersonIdent(
        author.getName(anonymousCowardName),
        author.getId().get() + "@" + serverId,
        when,
        serverIdent.getTimeZone());
  }

  public boolean getWriteJson() {
    return writeJson;
  }

  public Gson getGson() {
    return gson;
  }

  public String getServerId() {
    return serverId;
  }

  public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
      throws ConfigInvalidException {
    String email = ident.getEmailAddress();
    int at = email.indexOf('@');
    if (at >= 0) {
      String host = email.substring(at + 1, email.length());
      if (host.equals(serverId)) {
        Integer id = Ints.tryParse(email.substring(0, at));
        if (id != null) {
          return new Account.Id(id);
        }
      }
    }
    throw parseException(changeId, "invalid identity, expected @%s: %s", serverId, email);
  }

  private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
    int m = RawParseUtils.match(note, p.value, expected);
    return m == p.value + expected.length;
  }

  public List parseNote(byte[] note, MutableInteger p, Change.Id changeId)
      throws ConfigInvalidException {
    if (p.value >= note.length) {
      return ImmutableList.of();
    }
    Set seen = new HashSet<>();
    List result = new ArrayList<>();
    int sizeOfNote = note.length;
    byte[] psb = PATCH_SET.getBytes(UTF_8);
    byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
    byte[] bpn = PARENT_NUMBER.getBytes(UTF_8);

    RevId revId = new RevId(parseStringField(note, p, changeId, REVISION));
    String fileName = null;
    PatchSet.Id psId = null;
    boolean isForBase = false;
    Integer parentNumber = null;

    while (p.value < sizeOfNote) {
      boolean matchPs = match(note, p, psb);
      boolean matchBase = match(note, p, bpsb);
      if (matchPs) {
        fileName = null;
        psId = parsePsId(note, p, changeId, PATCH_SET);
        isForBase = false;
      } else if (matchBase) {
        fileName = null;
        psId = parsePsId(note, p, changeId, BASE_PATCH_SET);
        isForBase = true;
        if (match(note, p, bpn)) {
          parentNumber = parseParentNumber(note, p, changeId);
        }
      } else if (psId == null) {
        throw parseException(changeId, "missing %s or %s header", PATCH_SET, BASE_PATCH_SET);
      }

      Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber);
      fileName = c.key.filename;
      if (!seen.add(c.key)) {
        throw parseException(changeId, "multiple comments for %s in note", c.key);
      }
      result.add(c);
    }
    return result;
  }

  private Comment parseComment(
      byte[] note,
      MutableInteger curr,
      String currentFileName,
      PatchSet.Id psId,
      RevId revId,
      boolean isForBase,
      Integer parentNumber)
      throws ConfigInvalidException {
    Change.Id changeId = psId.getParentKey();

    // Check if there is a new file.
    boolean newFile = (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
    if (newFile) {
      // If so, parse the new file name.
      currentFileName = parseFilename(note, curr, changeId);
    } else if (currentFileName == null) {
      throw parseException(changeId, "could not parse %s", FILE);
    }

    CommentRange range = parseCommentRange(note, curr);
    if (range == null) {
      throw parseException(changeId, "could not parse %s", COMMENT_RANGE);
    }

    Timestamp commentTime = parseTimestamp(note, curr, changeId);
    Account.Id aId = parseAuthor(note, curr, changeId, AUTHOR);
    boolean hasRealAuthor =
        (RawParseUtils.match(note, curr.value, REAL_AUTHOR.getBytes(UTF_8))) != -1;
    Account.Id raId = null;
    if (hasRealAuthor) {
      raId = parseAuthor(note, curr, changeId, REAL_AUTHOR);
    }

    boolean hasParent = (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
    String parentUUID = null;
    boolean unresolved = false;
    if (hasParent) {
      parentUUID = parseStringField(note, curr, changeId, PARENT);
    }
    boolean hasUnresolved =
        (RawParseUtils.match(note, curr.value, UNRESOLVED.getBytes(UTF_8))) != -1;
    if (hasUnresolved) {
      unresolved = parseBooleanField(note, curr, changeId, UNRESOLVED);
    }

    String uuid = parseStringField(note, curr, changeId, UUID);

    boolean hasTag = (RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1;
    String tag = null;
    if (hasTag) {
      tag = parseStringField(note, curr, changeId, TAG);
    }

    int commentLength = parseCommentLength(note, curr, changeId);

    String message = RawParseUtils.decode(UTF_8, note, curr.value, curr.value + commentLength);
    checkResult(message, "message contents", changeId);

    Comment c =
        new Comment(
            new Comment.Key(uuid, currentFileName, psId.get()),
            aId,
            commentTime,
            isForBase ? (short) (parentNumber == null ? 0 : -parentNumber) : (short) 1,
            message,
            serverId,
            unresolved);
    c.lineNbr = range.getEndLine();
    c.parentUuid = parentUUID;
    c.tag = tag;
    c.setRevId(revId);
    if (raId != null) {
      c.setRealAuthor(raId);
    }

    if (range.getStartCharacter() != -1) {
      c.setRange(range);
    }

    curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
    curr.value = RawParseUtils.nextLF(note, curr.value);
    return c;
  }

  private static String parseStringField(
      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
      throws ConfigInvalidException {
    int endOfLine = RawParseUtils.nextLF(note, curr.value);
    checkHeaderLineFormat(note, curr, fieldName, changeId);
    int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
    curr.value = endOfLine;
    return RawParseUtils.decode(UTF_8, note, startOfField, endOfLine - 1);
  }

  /**
   * @return a comment range. If the comment range line in the note only has one number, we return a
   *     CommentRange with that one number as the end line and the other fields as -1. If the
   *     comment range line in the note contains a whole comment range, then we return a
   *     CommentRange with all fields set. If the line is not correctly formatted, return null.
   */
  private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
    CommentRange range = new CommentRange(-1, -1, -1, -1);

    int last = ptr.value;
    int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
    if (ptr.value == last) {
      return null;
    } else if (note[ptr.value] == '\n') {
      range.setEndLine(startLine);
      ptr.value += 1;
      return range;
    } else if (note[ptr.value] == ':') {
      range.setStartLine(startLine);
      ptr.value += 1;
    } else {
      return null;
    }

    last = ptr.value;
    int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
    if (ptr.value == last) {
      return null;
    } else if (note[ptr.value] == '-') {
      range.setStartCharacter(startChar);
      ptr.value += 1;
    } else {
      return null;
    }

    last = ptr.value;
    int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr);
    if (ptr.value == last) {
      return null;
    } else if (note[ptr.value] == ':') {
      range.setEndLine(endLine);
      ptr.value += 1;
    } else {
      return null;
    }

    last = ptr.value;
    int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr);
    if (ptr.value == last) {
      return null;
    } else if (note[ptr.value] == '\n') {
      range.setEndCharacter(endChar);
      ptr.value += 1;
    } else {
      return null;
    }
    return range;
  }

  private static PatchSet.Id parsePsId(
      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
      throws ConfigInvalidException {
    checkHeaderLineFormat(note, curr, fieldName, changeId);
    int startOfPsId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
    MutableInteger i = new MutableInteger();
    int patchSetId = RawParseUtils.parseBase10(note, startOfPsId, i);
    int endOfLine = RawParseUtils.nextLF(note, curr.value);
    if (i.value != endOfLine - 1) {
      throw parseException(changeId, "could not parse %s", fieldName);
    }
    checkResult(patchSetId, "patchset id", changeId);
    curr.value = endOfLine;
    return new PatchSet.Id(changeId, patchSetId);
  }

  private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId)
      throws ConfigInvalidException {
    checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId);

    int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
    MutableInteger i = new MutableInteger();
    int parentNumber = RawParseUtils.parseBase10(note, start, i);
    int endOfLine = RawParseUtils.nextLF(note, curr.value);
    if (i.value != endOfLine - 1) {
      throw parseException(changeId, "could not parse %s", PARENT_NUMBER);
    }
    checkResult(parentNumber, "parent number", changeId);
    curr.value = endOfLine;
    return Integer.valueOf(parentNumber);
  }

  private static String parseFilename(byte[] note, MutableInteger curr, Change.Id changeId)
      throws ConfigInvalidException {
    checkHeaderLineFormat(note, curr, FILE, changeId);
    int startOfFileName = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
    int endOfLine = RawParseUtils.nextLF(note, curr.value);
    curr.value = endOfLine;
    curr.value = RawParseUtils.nextLF(note, curr.value);
    return QuotedString.GIT_PATH.dequote(
        RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1));
  }

  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr, Change.Id changeId)
      throws ConfigInvalidException {
    int endOfLine = RawParseUtils.nextLF(note, curr.value);
    Timestamp commentTime;
    String dateString = RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
    try {
      commentTime = new Timestamp(GitDateParser.parse(dateString, null, Locale.US).getTime());
    } catch (ParseException e) {
      throw new ConfigInvalidException("could not parse comment timestamp", e);
    }
    curr.value = endOfLine;
    return checkResult(commentTime, "comment timestamp", changeId);
  }

  private Account.Id parseAuthor(
      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
      throws ConfigInvalidException {
    checkHeaderLineFormat(note, curr, fieldName, changeId);
    int startOfAccountId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
    PersonIdent ident = RawParseUtils.parsePersonIdent(note, startOfAccountId);
    Account.Id aId = parseIdent(ident, changeId);
    curr.value = RawParseUtils.nextLF(note, curr.value);
    return checkResult(aId, fieldName, changeId);
  }

  private static int parseCommentLength(byte[] note, MutableInteger curr, Change.Id changeId)
      throws ConfigInvalidException {
    checkHeaderLineFormat(note, curr, LENGTH, changeId);
    int startOfLength = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
    MutableInteger i = new MutableInteger();
    i.value = startOfLength;
    int commentLength = RawParseUtils.parseBase10(note, startOfLength, i);
    if (i.value == startOfLength) {
      throw parseException(changeId, "could not parse %s", LENGTH);
    }
    int endOfLine = RawParseUtils.nextLF(note, curr.value);
    if (i.value != endOfLine - 1) {
      throw parseException(changeId, "could not parse %s", LENGTH);
    }
    curr.value = endOfLine;
    return checkResult(commentLength, "comment length", changeId);
  }

  private boolean parseBooleanField(
      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
      throws ConfigInvalidException {
    String str = parseStringField(note, curr, changeId, fieldName);
    if ("true".equalsIgnoreCase(str)) {
      return true;
    } else if ("false".equalsIgnoreCase(str)) {
      return false;
    }
    throw parseException(changeId, "invalid boolean for %s: %s", fieldName, str);
  }

  private static  T checkResult(T o, String fieldName, Change.Id changeId)
      throws ConfigInvalidException {
    if (o == null) {
      throw parseException(changeId, "could not parse %s", fieldName);
    }
    return o;
  }

  private static int checkResult(int i, String fieldName, Change.Id changeId)
      throws ConfigInvalidException {
    if (i <= 0) {
      throw parseException(changeId, "could not parse %s", fieldName);
    }
    return i;
  }

  private void appendHeaderField(PrintWriter writer, String field, String value) {
    writer.print(field);
    writer.print(": ");
    writer.print(value);
    writer.print('\n');
  }

  private static void checkHeaderLineFormat(
      byte[] note, MutableInteger curr, String fieldName, Change.Id changeId)
      throws ConfigInvalidException {
    boolean correct = RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
    int p = curr.value + fieldName.length();
    correct &= (p < note.length && note[p] == ':');
    p++;
    correct &= (p < note.length && note[p] == ' ');
    if (!correct) {
      throw parseException(changeId, "could not parse %s", fieldName);
    }
  }

  /**
   * Build a note that contains the metadata for and the contents of all of the comments in the
   * given comments.
   *
   * @param comments Comments to be written to the output stream, keyed by patch set ID; multiple
   *     patch sets are allowed since base revisions may be shared across patch sets. All of the
   *     comments must share the same RevId, and all the comments for a given patch set must have
   *     the same side.
   * @param out output stream to write to.
   */
  void buildNote(ListMultimap comments, OutputStream out) {
    if (comments.isEmpty()) {
      return;
    }

    List psIds = new ArrayList<>(comments.keySet());
    Collections.sort(psIds);

    OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
    try (PrintWriter writer = new PrintWriter(streamWriter)) {
      String revId = comments.values().iterator().next().revId;
      appendHeaderField(writer, REVISION, revId);

      for (int psId : psIds) {
        List psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
        Comment first = psComments.get(0);

        short side = first.side;
        appendHeaderField(writer, side <= 0 ? BASE_PATCH_SET : PATCH_SET, Integer.toString(psId));
        if (side < 0) {
          appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
        }

        String currentFilename = null;

        for (Comment c : psComments) {
          checkArgument(
              revId.equals(c.revId),
              "All comments being added must have all the same RevId. The "
                  + "comment below does not have the same RevId as the others "
                  + "(%s).\n%s",
              revId,
              c);
          checkArgument(
              side == c.side,
              "All comments being added must all have the same side. The "
                  + "comment below does not have the same side as the others "
                  + "(%s).\n%s",
              side,
              c);
          String commentFilename = QuotedString.GIT_PATH.quote(c.key.filename);

          if (!commentFilename.equals(currentFilename)) {
            currentFilename = commentFilename;
            writer.print("File: ");
            writer.print(commentFilename);
            writer.print("\n\n");
          }

          appendOneComment(writer, c);
        }
      }
    }
  }

  private void appendOneComment(PrintWriter writer, Comment c) {
    // The CommentRange field for a comment is allowed to be null. If it is
    // null, then in the first line, we simply use the line number field for a
    // comment instead. If it isn't null, we write the comment range itself.
    Comment.Range range = c.range;
    if (range != null) {
      writer.print(range.startLine);
      writer.print(':');
      writer.print(range.startChar);
      writer.print('-');
      writer.print(range.endLine);
      writer.print(':');
      writer.print(range.endChar);
    } else {
      writer.print(c.lineNbr);
    }
    writer.print("\n");

    writer.print(formatTime(serverIdent, c.writtenOn));
    writer.print("\n");

    appendIdent(writer, AUTHOR, c.author.getId(), c.writtenOn);
    if (!c.getRealAuthor().equals(c.author)) {
      appendIdent(writer, REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn);
    }

    String parent = c.parentUuid;
    if (parent != null) {
      appendHeaderField(writer, PARENT, parent);
    }

    appendHeaderField(writer, UNRESOLVED, Boolean.toString(c.unresolved));
    appendHeaderField(writer, UUID, c.key.uuid);

    if (c.tag != null) {
      appendHeaderField(writer, TAG, c.tag);
    }

    byte[] messageBytes = c.message.getBytes(UTF_8);
    appendHeaderField(writer, LENGTH, Integer.toString(messageBytes.length));

    writer.print(c.message);
    writer.print("\n\n");
  }

  private void appendIdent(PrintWriter writer, String header, Account.Id id, Timestamp ts) {
    PersonIdent ident =
        newIdent(accountCache.get(id).getAccount(), ts, serverIdent, anonymousCowardName);
    StringBuilder name = new StringBuilder();
    PersonIdent.appendSanitized(name, ident.getName());
    name.append(" <");
    PersonIdent.appendSanitized(name, ident.getEmailAddress());
    name.append('>');
    appendHeaderField(writer, header, name.toString());
  }

  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");

  static String sanitizeFooter(String value) {
    // Remove characters that would confuse JGit's footer parser if they were
    // included in footer values, for example by splitting the footer block into
    // multiple paragraphs.
    //
    // One painful example: RevCommit#getShorMessage() might return a message
    // containing "\r\r", which RevCommit#getFooterLines() will treat as an
    // empty paragraph for the purposes of footer parsing.
    return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' ');
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy