add support for git operation through ssh
+ 833
- 181
@@ -10,7 +10,7 @@ package-lock.json
 
 # Ignore built public files
 public/.islands/
-public/instant-router.js
+# public/instant-router.js
 public/instant-router.js.map
 public/islands-runtime.js
 public/islands-runtime.js.map

@@ -85,6 +85,79 @@ RUN git rev-parse HEAD > .gitstamp
 COPY ./.gitstamp    /app/.gitstamp
 RUN rm -rf /app/.git
 
+# Install required dependencies
+RUN apt-get update -y && apt-get install git-core openssh-server gnupg sudo curl jq ca-certificates -y
+RUN mkdir -p /etc/apt/keyrings && \
+    curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
+        | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
+    echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
+        | tee /etc/apt/sources.list.d/nodesource.list && \
+    apt-get -qq update && \
+    apt-get -qq -y install --no-install-recommends \
+      nodejs=$(apt-cache show nodejs | grep -F 'Version: 20.0.0' | cut -f 2 -d ' ')
+
+# Add git-shell to system shells
+RUN echo "/usr/bin/git-shell" >> /etc/shells
+# RUN chsh -s /usr/bin/git-shell
+
+# Create git user
+RUN adduser git
+# RUN usermod -u 1000 git
+
+# Change git user shell to use git-shell
+# RUN usermod --shell /usr/bin/git-shell git
+RUN usermod --shell /usr/bin/sh git
+
+# Setup git user home repos' folder
+RUN mkdir /home/git/repos
+RUN chown git:git -R /home/git/repos
+RUN usermod --home /home/git/repos git
+
+# Make it possible for git user to chsh
+RUN sed -i -E 's/auth       required   pam_shells.so/auth       sufficient   pam_shells.so/' /etc/pam.d/chsh
+# Enable Password-less SSH Authentication (private-keys only)
+RUN sed -i -E 's/#?PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
+# RUN echo "ForceCommand /usr/bin/ssh_command" >> /etc/ssh/sshd_config
+RUN echo "AllowUsers root git" >> /etc/ssh/sshd_config
+RUN echo "AuthorizedKeysFile .ssh/authorized_keys /home/git/.ssh/authorized_keys" >> /etc/ssh/sshd_config
+
+# Empty machine motd
+RUN sed -i -E 's|session    optional     pam_motd.so  motd=/run/motd.dynamic|#session    optional     pam_motd.so  motd=/run/motd.dynamic|' /etc/pam.d/sshd
+RUN sed -i -E 's|session    optional     pam_motd.so noupdate|#session    optional     pam_motd.so noupdate|' /etc/pam.d/sshd
+RUN echo "" > /etc/motd
+
+# Change to git user home dir
+WORKDIR /home/git/
+
+# Add git-shell command no-interactive-login 
+RUN mkdir git-shell-commands/
+COPY ./data/git-shell-commands/no-interactive-login /home/git/git-shell-commands/no-interactive-login
+RUN chown git:git git-shell-commands/no-interactive-login
+RUN chmod +x git-shell-commands/no-interactive-login
+
+# Add ssh command to force client command
+COPY ./data/ssh_command /usr/bin/
+COPY ./data/ssh_command_node /usr/bin/
+RUN chmod +x /usr/bin/ssh_command
+RUN chmod +x /usr/bin/ssh_command_node
+
+# Setup ssh folder and keys
+RUN mkdir -p .ssh
+RUN chmod 700 .ssh
+RUN touch .ssh/authorized_keys
+COPY ./data/authorized_keys .ssh/authorized_keys
+RUN chmod 600 .ssh/authorized_keys
+RUN chown git:git -R .ssh
+
+# Switch to root user
+USER root
+WORKDIR /home/git
+
+RUN service ssh start
+
+EXPOSE 22
 EXPOSE ${PORT}
 
-CMD ["node", "app/server.js"]
+WORKDIR /app
+
+CMD ["/bin/bash", "-c", "/usr/sbin/sshd -D & nohup; node app/server.js"]

new file
Dockerfile.git-ssh
@@ -0,0 +1,66 @@
+# syntax=docker/dockerfile:1
+FROM debian:12
+
+# Install required dependencies
+RUN apt-get update -y && apt-get install git-core openssh-server sudo curl -y
+RUN curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
+RUN apt-get install nodejs -y
+
+# Add git-shell to system shells
+RUN echo "/usr/bin/git-shell" >> /etc/shells
+# RUN chsh -s /usr/bin/git-shell
+
+# Create git user
+RUN adduser git
+RUN usermod -u 1000 git
+
+# Change git user shell to use git-shell
+# RUN usermod --shell /usr/bin/git-shell git
+RUN usermod --shell /usr/bin/sh git
+
+# Setup git user home repos' folder
+RUN mkdir /home/git/repos
+RUN chown git:git -R /home/git/repos
+RUN usermod --home /home/git/repos git
+
+# Make it possible for git user to chsh
+RUN sed -i -E 's/auth       required   pam_shells.so/auth       sufficient   pam_shells.so/' /etc/pam.d/chsh
+# Enable Password-less SSH Authentication (private-keys only)
+RUN sed -i -E 's/#?PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
+# RUN echo "ForceCommand /usr/bin/ssh_command" >> /etc/ssh/sshd_config
+RUN echo "AllowUsers root git" >> /etc/ssh/sshd_config
+RUN echo "AuthorizedKeysFile .ssh/authorized_keys /home/git/.ssh/authorized_keys" >> /etc/ssh/sshd_config
+
+# Empty machine motd
+RUN sed -i -E 's|session    optional     pam_motd.so  motd=/run/motd.dynamic|#session    optional     pam_motd.so  motd=/run/motd.dynamic|' /etc/pam.d/sshd
+RUN sed -i -E 's|session    optional     pam_motd.so noupdate|#session    optional     pam_motd.so noupdate|' /etc/pam.d/sshd
+RUN echo "" > /etc/motd
+
+# Change to git user home dir
+WORKDIR /home/git/
+
+# Add git-shell command no-interactive-login 
+RUN mkdir git-shell-commands/
+COPY ./data/git-shell-commands/no-interactive-login /home/git/git-shell-commands/no-interactive-login
+RUN chown git:git git-shell-commands/no-interactive-login
+RUN chmod +x git-shell-commands/no-interactive-login
+
+# Add ssh command to force client command
+COPY ./data/ssh_command /usr/bin/
+RUN chmod +x /usr/bin/ssh_command
+
+# Setup ssh folder and keys
+RUN mkdir -p .ssh
+RUN chmod 700 .ssh
+COPY ./data/authorized_keys .ssh/authorized_keys
+RUN chmod 600 .ssh/authorized_keys
+RUN chown git:git -R .ssh
+
+# Switch to root user
+USER root
+WORKDIR /home/git
+
+RUN service ssh start
+EXPOSE 22
+
+CMD ["/usr/sbin/sshd","-D"]

@@ -1,11 +1,11 @@
 {
-  "_generatedAtUnix": 1702180342118,
+  "_generatedAtUnix": 1702820538258,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "assets": {
     "IslandsRuntime": {
       "kind": "generated",
-      "hash": "81cd1baaa8b7c287f98b563607aaeeaa2884158a",
+      "hash": "3fee654387a4e627cbd5ee9b35238f9157ea7eac",
       "path": "./public/instant-router.js"
     },
     "InstantRouter": {

...
@@ -16,7 +16,7 @@
   },
   "islands": {
     "Code": {
-      "hash": "c3c638cfcd010020f6ca251f7a3984721b60048d",
+      "hash": "4b585b9dc0f2dcd0dacbfed17fcf8c0c4f40e0e8",
       "pathSource": "./app/islands/Code.tsx",
       "pathBundle": "./public/.islands/Code.bundle.js",
       "pathSourceMap": "./public/.islands/Code.bundle.js.map"

...
@@ -40,7 +40,7 @@
       "pathSourceMap": "./public/.islands/RepositoriesList.bundle.js.map"
     },
     "RepositoryCommitSummaryLine": {
-      "hash": "11d260b4acaf7aca6fc2b746529617093f3a3356",
+      "hash": "f1b583339bcae5d3a207139106daee95924db9b9",
       "pathSource": "./app/islands/RepositoryCommitSummaryLine.tsx",
       "pathBundle": "./public/.islands/RepositoryCommitSummaryLine.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryCommitSummaryLine.bundle.js.map"

...
@@ -52,7 +52,7 @@
       "pathSourceMap": "./public/.islands/RepositoryCreateForm.bundle.js.map"
     },
     "RepositoryFilesDiffsList": {
-      "hash": "fd96b0001b9db83675724042cfc0d0c8323fd07b",
+      "hash": "5a450fca443764deb2043fd1b70264cd7646e7ba",
       "pathSource": "./app/islands/RepositoryFilesDiffsList.tsx",
       "pathBundle": "./public/.islands/RepositoryFilesDiffsList.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryFilesDiffsList.bundle.js.map"

...
@@ -138,7 +138,7 @@
       "pathSource": "./app/views/repository/RepositoryForkView.tsx"
     },
     "RepositoryShowObjectView": {
-      "hash": "ee4a0c034ffbbc9ae6b257dca2121d629d7049bf",
+      "hash": "cf63c6b6caaac822f36ddf17e663c705b10b4aa6",
       "pathSource": "./app/views/repository/RepositoryShowObjectView.tsx"
     },
     "RepositoryPullRequestCreateView": {

@@ -0,0 +1,8 @@
+import React, { PropsWithChildren } from "react";
+
+// import { AppRoute } from "./routes.defs";
+// import HomeView from "./views/HomeView";
+
+export function App({ children }: PropsWithChildren<{}>) {
+  return <>{children}</>;
+}

new file
app/components/Chip.ts
@@ -0,0 +1,24 @@
+import Color from "color";
+import styled, { css } from "styled-components";
+
+export const Chip = styled.div<{ color?: string }>`
+  ${({ color = undefined }) => css`
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    justify-content: center;
+
+    padding: 3px 8px;
+    font-size: 14px;
+    line-height: 14px;
+
+    font-weight: bold;
+    text-transform: uppercase;
+
+    color: ${color ? Color(color).alpha(1).toString() : "black"};
+    background-color: ${color
+      ? Color(color).alpha(0.3).toString()
+      : "rgba(0, 0, 0, 0.3)"};
+    border-radius: 8px;
+  `};
+`;

app/controllers/index.ts
@@ -6,3 +6,4 @@ export { RepositoryPullRequestsController } from "./repositoryPullRequests";
 export { SyntaxHighlightController } from "./syntaxHighlight";
 export { ThemeController } from "./theme";
 export { UserController } from "./user";
+export { SSHAuthController } from "./ssh-auth";

new file
app/controllers/ssh-auth.ts
@@ -0,0 +1,79 @@
+// 3rd-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+
+import { GitServer } from "@ethicdevs/fastify-git-server";
+import { AppRoute, AppRouteParams } from "../routes.defs";
+import { makeGitServerService } from "../services/gitServer";
+
+const onSSHAuth: ReqHandler<AppRouteParams, AppRoute.SSH_AUTH> = async (
+  request,
+  reply
+) => {
+  const gitService = makeGitServerService({
+    request,
+    cryptoService: request.cryptoService,
+  });
+
+  // console.log("request:", request);
+
+  request.body =
+    typeof request.body === "string" ? JSON.parse(request.body) : request.body;
+
+  const { command, repoSlug, username, publicKey } = request.body;
+
+  console.log("command:", command);
+  console.log("repoSlug:", repoSlug);
+  console.log("username:", username);
+  console.log("publicKey:", publicKey);
+
+  const result = await gitService.repositoryResolver(
+    repoSlug.replace(/\.git$/, "")
+  );
+
+  let { authMode, gitRepositoryDir } = result;
+  gitRepositoryDir = gitRepositoryDir.toString().endsWith(".git")
+    ? gitRepositoryDir
+    : `${gitRepositoryDir}.git`;
+
+  if (
+    authMode === GitServer.AuthMode.NEVER ||
+    (authMode === GitServer.AuthMode.PUSH_ONLY &&
+      command !== "git-receive-pack") // push
+  ) {
+    console.log(
+      "no need for auth, repo is public/push_only and command is not push"
+    );
+
+    reply.status(200).send({
+      success: true,
+      authMode,
+      command,
+      gitRepositoryDir,
+    });
+    return;
+  }
+
+  const isAuthorizationValid = await gitService.authorizationResolver(
+    repoSlug.replace(/\.git$/, "") + ".pub",
+    {
+      username,
+      password: publicKey,
+    }
+  );
+
+  console.log(
+    "authorization result:",
+    isAuthorizationValid ? "valid" : "invalid"
+  );
+
+  reply.status(isAuthorizationValid ? 200 : 400).send({
+    success: isAuthorizationValid,
+    authMode,
+    command,
+    gitRepositoryDir,
+  });
+};
+
+export const SSHAuthController = {
+  onSSHAuth,
+};

@@ -1,7 +1,13 @@
 // 1st-party
 import { ReactIsland } from "@ethicdevs/react-monolith";
 // 3rd-party
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import React, {
+  CSSProperties,
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
 import Prism from "prismjs";
 import styled, { css } from "styled-components";
 // import { fetch } from "cross-fetch";

...
@@ -15,6 +21,7 @@ import { ClientSideRouterEvents } from "./InstantRouterIndicator";
 interface CodeProps {
   code: string;
   language: string;
+  style?: CSSProperties;
   [x: string]: unknown;
 }
 

...
@@ -40,6 +47,7 @@ const Code: ReactIsland<CodeProps & WithThemeSchemeProp> = ({
   code,
   language,
   themeScheme,
+  style,
   ...props
 }) => {
   const { before: lineStartAt } = useMemo(

...
@@ -161,7 +169,7 @@ const Code: ReactIsland<CodeProps & WithThemeSchemeProp> = ({
         data-start={lineStartAt}
         className={` language-${language} line-numbers`}
         themeScheme={themeScheme}
-        style={{ counterReset: "linenumber 0" }}
+        style={{ counterReset: "linenumber 0", ...(style || {}) }}
       >
         <StyledCodeTag {...props} dangerouslySetInnerHTML={innerHtml} />
       </StylePreTag>

app/islands/RepositoryCommitSummaryLine.tsx
@@ -13,109 +13,110 @@ export interface RepositoryCommitSummaryLineProps {
   currentRef: string;
   orgSlug: string;
   repoSlug: string;
+  defaultFullSubjectVisible?: boolean;
 }
 
 const MAX_COMMIT_LINE_LENGTH = 60;
 const TRAILING_CHAR = " ...";
 const TRAILING_CHAR_LENGTH = TRAILING_CHAR.length;
 
-const RepositoryCommitSummaryLine: ReactIsland<RepositoryCommitSummaryLineProps> =
-  ({ orgSlug, repoSlug, commit }) => {
-    const [isFullSubjectShown, setIsFullSubjectShown] =
-      useState<boolean>(false);
+const RepositoryCommitSummaryLine: ReactIsland<
+  RepositoryCommitSummaryLineProps
+> = ({ orgSlug, repoSlug, commit, defaultFullSubjectVisible = false }) => {
+  const [isFullSubjectShown, setIsFullSubjectShown] = useState<boolean>(
+    defaultFullSubjectVisible
+  );
 
-    const toggleFullSubjectVisibility = useCallback(
-      (ev: React.MouseEvent<HTMLSpanElement>) => {
-        ev.preventDefault();
-        setIsFullSubjectShown((prevVisibility) => !prevVisibility);
-      },
-      [setIsFullSubjectShown]
-    );
+  const toggleFullSubjectVisibility = useCallback(
+    (ev: React.MouseEvent<HTMLSpanElement>) => {
+      ev.preventDefault();
+      setIsFullSubjectShown((prevVisibility) => !prevVisibility);
+    },
+    [setIsFullSubjectShown]
+  );
 
-    const isSubjectTooLongForDisplay = useMemo(
-      () => commit.subject.length > MAX_COMMIT_LINE_LENGTH,
-      [commit.subject.length]
-    );
+  const isSubjectTooLongForDisplay = useMemo(
+    () => commit.subject.length > MAX_COMMIT_LINE_LENGTH,
+    [commit.subject.length]
+  );
 
-    const subject = useMemo(
-      () =>
-        isSubjectTooLongForDisplay
-          ? `${commit.subject.substring(
-              0,
-              MAX_COMMIT_LINE_LENGTH - TRAILING_CHAR_LENGTH
-            )}`
-          : commit.subject,
-      [commit.subject]
-    );
+  const subject = useMemo(
+    () =>
+      isSubjectTooLongForDisplay
+        ? `${commit.subject.substring(
+            0,
+            MAX_COMMIT_LINE_LENGTH - TRAILING_CHAR_LENGTH
+          )}`
+        : commit.subject,
+    [commit.subject]
+  );
 
-    return (
-      <Grid.Col fluid nowrap>
-        <Grid.Row
-          gap={8}
-          alignItems={"stretch"}
-          style={{ flexWrap: "wrap-reverse" }}
+  return (
+    <Grid.Col fluid nowrap>
+      <Grid.Row
+        gap={8}
+        alignItems={"stretch"}
+        style={{ flexWrap: "wrap-reverse" }}
+      >
+        <Grid.Col flex={"1 0 calc(100% - 220px)"} style={{ minWidth: 360 }}>
+          <strong>{commit.author.name}</strong>
+          <span style={{ marginTop: 8 }}>
+            <a
+              href={buildRouteLink(AppRoute.REPOSITORY_SHOW_OBJECT, {
+                orgSlug,
+                repoSlug,
+                objectId: commit.commit,
+              })}
+            >
+              {subject}
+            </a>
+            {isSubjectTooLongForDisplay ? (
+              <span onClick={toggleFullSubjectVisibility}>{TRAILING_CHAR}</span>
+            ) : null}
+          </span>
+        </Grid.Col>
+        <Grid.Col
+          alignItems={"flex-end"}
+          style={{
+            textAlign: "right",
+            flex: "1 0 200px",
+            width: 200,
+            minWidth: 200,
+          }}
         >
-          <Grid.Col flex={"1 0 calc(100% - 220px)"} style={{ minWidth: 360 }}>
-            <strong>{commit.author.name}</strong>
-            <span style={{ marginTop: 8 }}>
+          <span>
+            <a
+              href={buildRouteLink(AppRoute.REPOSITORY_SHOW_OBJECT, {
+                orgSlug,
+                repoSlug,
+                objectId: commit.commit,
+              })}
+            >
+              {commit.abbreviated_commit}
+            </a>
+            {commit.abbreviated_parent.trim() != "" ? (
               <a
                 href={buildRouteLink(AppRoute.REPOSITORY_SHOW_OBJECT, {
                   orgSlug,
                   repoSlug,
-                  objectId: commit.commit,
+                  objectId: commit.parent,
                 })}
               >
-                {subject}
+                {` (parent ${commit.abbreviated_parent})`}
               </a>
-              {isSubjectTooLongForDisplay ? (
-                <span onClick={toggleFullSubjectVisibility}>
-                  {TRAILING_CHAR}
-                </span>
-              ) : null}
-            </span>
-          </Grid.Col>
-          <Grid.Col
-            alignItems={"flex-end"}
-            style={{
-              textAlign: "right",
-              flex: "1 0 200px",
-              width: 200,
-              minWidth: 200,
-            }}
-          >
-            <span>
-              <a
-                href={buildRouteLink(AppRoute.REPOSITORY_SHOW_OBJECT, {
-                  orgSlug,
-                  repoSlug,
-                  objectId: commit.commit,
-                })}
-              >
-                {commit.abbreviated_commit}
-              </a>
-              {commit.abbreviated_parent.trim() != "" ? (
-                <a
-                  href={buildRouteLink(AppRoute.REPOSITORY_SHOW_OBJECT, {
-                    orgSlug,
-                    repoSlug,
-                    objectId: commit.parent,
-                  })}
-                >
-                  {` (parent ${commit.abbreviated_parent})`}
-                </a>
-              ) : null}
-            </span>
-            <span style={{ marginTop: 8 }}>
-              {new Date(commit.author.date).toLocaleString()}
-            </span>
-          </Grid.Col>
-        </Grid.Row>
-        {isFullSubjectShown ? (
-          <code style={{ marginTop: 8 }}>{commit.subject}</code>
-        ) : null}
-      </Grid.Col>
-    );
-  };
+            ) : null}
+          </span>
+          <span style={{ marginTop: 8 }}>
+            {new Date(commit.author.date).toLocaleString()}
+          </span>
+        </Grid.Col>
+      </Grid.Row>
+      {isFullSubjectShown ? (
+        <code style={{ marginTop: 8 }}>{commit.subject}</code>
+      ) : null}
+    </Grid.Col>
+  );
+};
 
 RepositoryCommitSummaryLine.displayName = "RepositoryCommitSummaryLine";
 export default RepositoryCommitSummaryLine;

app/islands/RepositoryFilesDiffsList.tsx
@@ -11,7 +11,9 @@ import type {
 import { AppRoute } from "../routes.defs";
 import { Const } from "../const";
 import { Card } from "../components/Card.styled";
+import { Chip } from "../components/Chip";
 import { Grid } from "../components/Grid";
+import { NamedColors } from "../utils/style";
 import { buildRouteLink } from "../utils/shared";
 // app islands
 import Code, { getThemedCodeCss } from "../islands/Code";

...
@@ -41,28 +43,46 @@ const RepositoryFilesDiffsList: ReactIsland<
         {filesDiffs.map(({ chunks, ...diff }, idx) => (
           <Card
             key={[diff.from, diff.to].join(":")}
-            style={{ marginTop: idx > 0 ? 16 : 0, width: "100%" }}
+            style={{ marginTop: idx > 0 ? 16 : 0, width: "100%", padding: 8 }}
             themeScheme={themeScheme}
           >
             <Grid.Col fluid nowrap>
-              <Grid.Row fluid nowrap>
-                <strong>{diff.from}</strong>
-                <span style={{ marginLeft: 16 }}>{" -> "}</span>
-                <strong style={{ marginLeft: 16 }}>{diff.to}</strong>
+              <Grid.Row fluid nowrap gap={12} alignItems={"center"}>
+                {diff.from === "/dev/null" ? (
+                  <Chip color={"rgb(43, 176, 90)"}>new file</Chip>
+                ) : diff.to !== "/dev/null" ? (
+                  <strong>{diff.from}</strong>
+                ) : null}
+                {diff.to !== diff.from && (
+                  <>
+                    {diff.to !== "/dev/null" && diff.from !== "/dev/null" && (
+                      <span>{" -> "}</span>
+                    )}
+                    {diff.to === "/dev/null" ? (
+                      <>
+                        <Chip color={"rgb(215, 44, 44)"}>file deleted</Chip>
+                        <strong>{diff.from}</strong>
+                      </>
+                    ) : (
+                      <strong>{diff.to}</strong>
+                    )}
+                  </>
+                )}
               </Grid.Row>
               <Grid.Row
                 fluid
                 nowrap
                 alignItems={"center"}
                 style={{ marginTop: 8 }}
+                gap={16}
               >
-                <div>
-                  <strong>additions:</strong> <span>{diff.additions}</span>
+                <div style={{ color: "rgb(43, 176, 90)" }}>
+                  <strong>+</strong> <span>{diff.additions}</span>
                 </div>
-                <div style={{ marginLeft: 16 }}>
-                  <strong>deletions:</strong> <span>{diff.deletions}</span>
+                <div style={{ color: "rgb(215, 44, 44)" }}>
+                  <strong>-</strong> <span>{diff.deletions}</span>
                 </div>
-                <div style={{ marginLeft: 16 }}>
+                <div>
                   <a
                     href={buildRouteLink(
                       AppRoute.REPOSITORY_BROWSER_WITH_PATH,

...
@@ -70,7 +90,7 @@ const RepositoryFilesDiffsList: ReactIsland<
                         orgSlug,
                         repoSlug,
                         currentRef: commitHash,
-                        "*": diff.to,
+                        "*": diff.to === "/dev/null" ? diff.from : diff.to,
                       }
                     )}
                   >

...
@@ -83,7 +103,7 @@ const RepositoryFilesDiffsList: ReactIsland<
                         orgSlug,
                         repoSlug,
                         currentRef: Const.PRIMARY_BRANCH_REF,
-                        "*": diff.to,
+                        "*": diff.to === "/dev/null" ? diff.from : diff.to,
                       }
                     )}
                     style={{ marginLeft: 16 }}

...
@@ -104,7 +124,30 @@ const RepositoryFilesDiffsList: ReactIsland<
                     code={getChunkContent(chunk)}
                     language={"diff"}
                     themeScheme={themeScheme}
+                    style={{
+                      borderTopLeftRadius: subIdx === 0 ? 8 : 0,
+                      borderTopRightRadius: subIdx === 0 ? 8 : 0,
+                      borderBottomLeftRadius:
+                        subIdx === chunks.length - 1 ? 8 : 0,
+                      borderBottomRightRadius:
+                        subIdx === chunks.length - 1 ? 8 : 0,
+                    }}
                   />
+                  {subIdx < chunks.length - 1 && (
+                    <Grid.Row
+                      fluid
+                      alignItems={"center"}
+                      style={{
+                        height: 30,
+                        width: "100%",
+                        border: `1px solid ${NamedColors.BORDER_DEFAULT[themeScheme]}`,
+                        padding: "0 10px",
+                        opacity: 0.7,
+                      }}
+                    >
+                      ...
+                    </Grid.Row>
+                  )}
                 </div>
               ))}
             </Grid.Col>

@@ -9,6 +9,7 @@ import { PullRequestFormState } from "./islands/RepositoryPullRequestCreateForm"
 
 export enum AppRoute {
   HOME = "home",
+  SSH_AUTH = "ssh.auth_helper",
   THEME_SET_SCHEME_ACTION = "theme.set_scheme.action",
   AUTH_REGISTER = "auth.register",
   AUTH_REGISTER_ACTION = "auth.register.action",

...
@@ -46,6 +47,7 @@ export enum AppRoute {
 
 export const AppRoutePaths: Record<AppRoute, string> = {
   [AppRoute.HOME]: "/",
+  [AppRoute.SSH_AUTH]: "/_ssh/auth",
   [AppRoute.THEME_SET_SCHEME_ACTION]: "/theme/:themeScheme",
   [AppRoute.AUTH_REGISTER]: "/auth/register",
   [AppRoute.AUTH_REGISTER_ACTION]: "/auth/register",

...
@@ -95,6 +97,15 @@ export const AppRoutePaths: Record<AppRoute, string> = {
 export interface AppRouteParams {
   [x: string]: any;
   [AppRoute.HOME]: undefined;
+  [AppRoute.SSH_AUTH]: {
+    body: {
+      // git-receive-pack: push, git-upload-pack: clone, pull, fetch
+      command: "git-receive-pack" | "git-upload-pack";
+      repoSlug: string;
+      username: string;
+      publicKey: string;
+    };
+  };
   [AppRoute.THEME_SET_SCHEME_ACTION]: {
     params: {
       themeScheme: AppThemeScheme;

...
@@ -331,6 +342,29 @@ export interface AppRouteParams {
 
 export const AppRouteSchemas: Record<AppRoute, undefined | FastifySchema> = {
   [AppRoute.HOME]: undefined,
+  [AppRoute.SSH_AUTH]: {
+    body: {
+      type: "object",
+      required: ["command", "repoSlug", "username", "publicKey"],
+      additionalProperties: false,
+      properties: {
+        // git-receive-pack: push, git-upload-pack: clone, pull, fetch
+        command: {
+          type: "string",
+          enum: ["git-receive-pack", "git-upload-pack"],
+        },
+        repoSlug: {
+          type: "string",
+        },
+        username: {
+          type: "string",
+        },
+        publicKey: {
+          type: "string",
+        },
+      },
+    },
+  },
   [AppRoute.THEME_SET_SCHEME_ACTION]: {
     params: {
       type: "object",

@@ -25,6 +25,7 @@ import {
   OrganizationController,
   RepositoryController,
   RepositoryPullRequestsController,
+  SSHAuthController,
   SyntaxHighlightController,
   ThemeController,
   UserController,

...
@@ -65,6 +66,12 @@ const RootAppRouter: AppRouter<AppRouteParams> = () => {
           preHandler={guestOrDashboardRedirect}
           handler={HomeController.getHomeView}
         />
+        <Route
+          name={AppRoute.SSH_AUTH}
+          method={"POST"}
+          path={AppRoutePaths[AppRoute.SSH_AUTH]}
+          handler={SSHAuthController.onSSHAuth}
+        />
         {/* --- */}
         <Route
           name={AppRoute.AUTH_REGISTER}

@@ -28,6 +28,7 @@ import InternalErrorView, {
   InternalErrorViewProps,
 } from "./views/InternalErrorView";
 // app
+import { App } from "./App";
 import { AppRouteParams } from "./routes.defs";
 import { Const } from "./const";
 import { Env } from "./env";

...
@@ -75,7 +76,7 @@ async function main(): Promise<AppServer> {
     featureFlags: {
       withIncrementalBuild: true,
       withImportsMap: true,
-      withInstantRouter: true,
+      withInstantRouter: false,
       withStyledSSR: true,
       withTreeShaking: true,
       withDefaultErrorHandlers: false,

...
@@ -88,6 +89,9 @@ async function main(): Promise<AppServer> {
       routesFile: Paths.ROUTES_FILE,
       viewsFolder: Paths.VIEWS_FOLDER,
     },
+    specialComponents: {
+      AppComponent: App,
+    },
     externalDependencies: {
       "cross-fetch": "CrossFetch",
       "markdown-to-jsx": "MarkdownToJSX",

...
@@ -274,6 +278,12 @@ async function main(): Promise<AppServer> {
           } as any,
         });
 
+        s.decorateRequest("gitService", {
+          get() {
+            return gitService;
+          },
+        });
+
         // register the Git Server plugin and bind the resolvers/callbacks
         // to the gitService instance made above
         s.register(fastifyGitServer, {

app/services/gitServer/authorizationResolver.ts
@@ -11,9 +11,13 @@ const makeAuthorizationResolver: ServiceMethodFactory<
   PromiseLike<boolean>
 > = ({ cryptoService, request }) => {
   return async (repoPath, { username, password }) => {
-    const [orgSlug, repoSlug] = repoPath.split("/");
+    const [orgSlug, repoSlugUnsafe] = repoPath.split("/");
+
+    // password contains the publicKey instead of regular password
+    const isPubKeyAuth = repoSlugUnsafe.endsWith(".pub");
+
+    const repoSlug = repoSlugUnsafe.replace(/\.(git|pub)$/, "");
 
-    const hashedPassword = cryptoService.computeHash(password);
     const user = await request.prisma.user.findUnique({
       where: {
         username,

...
@@ -51,16 +55,37 @@ const makeAuthorizationResolver: ServiceMethodFactory<
       return false;
     }
 
+    const repoOrgOwnerIsReqUser = org.ownerId === user.id;
+    const repoMembershipsHasReqUser =
+      org.memberships.find((m) => m.id === user.id) != null;
+
     if (repo.visibility === ResourceVisibility.PUBLIC) {
       return true;
     } else {
-      // TODO:
-      // allow read-only for unlisted users without auth, but write behind auth.
-      return !!(
-        (org.ownerId === user.id ||
-          org.memberships.find((m) => m.id === user.id)) &&
-        hashedPassword === user.hashedPassword
-      );
+      // TODO: allow read-only for unlisted users without auth, but write behind auth.
+      if (
+        repoOrgOwnerIsReqUser === false &&
+        repoMembershipsHasReqUser === false
+      ) {
+        return false;
+      }
+
+      if (isPubKeyAuth) {
+        const matchingPk = await request.prisma.userSSHKey.findFirst({
+          where: {
+            // password contains the publicKey instead of regular password
+            key: password,
+          },
+          include: {
+            user: true,
+          },
+        });
+
+        return matchingPk != null && matchingPk.user.id === user.id;
+      } else {
+        const hashedPassword = cryptoService.computeHash(password);
+        return hashedPassword === user.hashedPassword;
+      }
     }
   };
 };

app/utils/server/localAppDomainPreHandler.ts
@@ -1,35 +1,35 @@
 // 3rd-party
-import { AppServer } from "@ethicdevs/react-monolith";
+// import { AppServer } from "@ethicdevs/react-monolith";
 import { preHandlerHookHandler } from "fastify";
 // lib
-import { Env } from "../../env";
+// import { Env } from "../../env";
 
 export const localAppDomainPreHandler: preHandlerHookHandler = (
-  request,
-  reply,
+  _request,
+  _reply,
   done
 ) => {
-  const { $port } = (request.server as AppServer).reactMonolith!;
+  // const { $port } = (request.server as AppServer).reactMonolith!;
 
   // Safe-guard local requests to not forget because setting cookies on
   // the `localhost` domain is not consistent across browsers and lead to
   // testing/developing kind of "on the wrong env". To fix this we use an
   // entry in the /etc/hosts file that maps to a locally known domain name
   // on which we can set cookies as if we were in production mode/real domain.
-  if (request.hostname === `localhost:${$port}`) {
-    console.log(
-      `--- REQUEST TO 'localhost' DETECTED, PLEASE USE '${Env.DEPLOYMENT_DOMAIN}' INSTEAD FOR COOKIES TO WORK! ---`
-    );
-    console.log(
-      `--- MAKE SURE YOU HAVE '127.0.0.1   ${Env.DEPLOYMENT_DOMAIN}' SET IN YOUR '/etc/hosts' FILE! ---`
-    );
-    console.log(
-      `--- REDIRECTED TO 'http://${Env.DEPLOYMENT_DOMAIN}:${$port}' ---`
-    );
-    reply.redirect(
-      301,
-      `http://${Env.DEPLOYMENT_DOMAIN}:${$port}${request.url}`
-    );
-  }
+  // if (request.hostname === `localhost:${$port}`) {
+  //   console.log(
+  //     `--- REQUEST TO 'localhost' DETECTED, PLEASE USE '${Env.DEPLOYMENT_DOMAIN}' INSTEAD FOR COOKIES TO WORK! ---`
+  //   );
+  //   console.log(
+  //     `--- MAKE SURE YOU HAVE '127.0.0.1   ${Env.DEPLOYMENT_DOMAIN}' SET IN YOUR '/etc/hosts' FILE! ---`
+  //   );
+  //   console.log(
+  //     `--- REDIRECTED TO 'http://${Env.DEPLOYMENT_DOMAIN}:${$port}' ---`
+  //   );
+  //   reply.redirect(
+  //     301,
+  //     `http://${Env.DEPLOYMENT_DOMAIN}:${$port}${request.url}`
+  //   );
+  // }
   done();
 };

app/views/repository/RepositoryShowObjectView.tsx
@@ -11,7 +11,13 @@ import type {
   RepositoryObject,
   RepositoryWithForkedFromRepo,
 } from "../../types";
-import { Card, IslandWrapper, Layout, PageWrapper } from "../../components";
+import {
+  Card,
+  Grid,
+  IslandWrapper,
+  Layout,
+  PageWrapper,
+} from "../../components";
 // app islands
 import Code, { getThemedCodeCss } from "../../islands/Code";
 import RepositoryCommitSummaryLine from "../../islands/RepositoryCommitSummaryLine";

...
@@ -34,6 +40,15 @@ const RepositoryShowObjectView: ReactView<RepositoryShowObjectViewProps> = ({
   parentOrg,
   repo,
 }) => {
+  const totalAdditions = gitObjectDiffs?.reduce(
+    (acc, obj) => (acc += obj.additions),
+    0
+  );
+  const totalDeletions = gitObjectDiffs?.reduce(
+    (acc, obj) => (acc += obj.deletions),
+    0
+  );
+
   return (
     <Layout {...commonProps}>
       <PageWrapper>

...
@@ -50,36 +65,42 @@ const RepositoryShowObjectView: ReactView<RepositoryShowObjectViewProps> = ({
 
         <Card
           data-islandid={`${RepositoryCommitSummaryLine.name}$$0`}
-          style={{ width: "100%", marginTop: 32, padding: 8 }}
+          style={{ width: "100%", marginTop: 32, padding: 8, gap: 8 }}
           themeScheme={commonProps.themeScheme}
         >
           <RepositoryCommitSummaryLine
+            defaultFullSubjectVisible
             commit={gitObject}
             currentRef={currentRef}
             orgSlug={parentOrg.slug}
             repoSlug={repo.slug}
           />
+          <Grid.Row fluid nowrap alignItems={"center"} gap={16}>
+            <div style={{ color: "rgb(43, 176, 90)" }}>
+              <strong>+</strong> <span>{totalAdditions}</span>
+            </div>
+            <div style={{ color: "rgb(215, 44, 44)" }}>
+              <strong>-</strong> <span>{totalDeletions}</span>
+            </div>
+          </Grid.Row>
+          {gitObject.body.trim() !== "" && (
+            <div style={{ width: "100%" }}>
+              {getThemedCodeCss(commonProps.themeScheme)}
+              <div data-islandid={`${Code.name}$$0`} style={{ width: "100%" }}>
+                <Code
+                  language={"plain"}
+                  code={gitObject.body}
+                  themeScheme={commonProps.themeScheme}
+                />
+              </div>
+            </div>
+          )}
         </Card>
-        {gitObject.body.trim() !== "" && (
-          <>
-            {getThemedCodeCss(commonProps.themeScheme)}
-            <Card
-              data-islandid={`${Code.name}$$0`}
-              style={{ width: "100%", marginTop: 32 }}
-              themeScheme={commonProps.themeScheme}
-            >
-              <Code
-                language={"plain"}
-                code={gitObject.body}
-                themeScheme={commonProps.themeScheme}
-              />
-            </Card>
-          </>
-        )}
+
         {gitObjectDiffs != null && (
           <Card
             data-islandid={`${RepositoryFilesDiffsList.name}$$0`}
-            style={{ width: "100%", marginTop: 16 }}
+            style={{ width: "100%", marginTop: 24, padding: 8 }}
             themeScheme={commonProps.themeScheme}
           >
             <RepositoryFilesDiffsList

new file
data/authorized_keys
@@ -0,0 +1 @@
+command="ssh_command wnemencha",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDGo9GDFyJ8RJ4F+Y4Vxl2bCl8sskRYbiSWItmoBZoYFVR2qAvUyowDY2xlDa9JaG/+zbBB6sUwUr8oC/GdSaV+zp5CTP2RxcXDW2Aup6w1/a4qiilSKORMXBWvIgjyvVvHjG0TiCfeC0PfBbXCO8FhLxP5lgTrl1kVUduM0LR4/gcH3vIrrKbORWtfAC7Bw6z3qc/X9CysPxtQZYu6+AknJ1vwUtLAH2H9cKS8uwaJ5N/k0n8Sc8ANozdpp7EyodA12nFFwvf5oPakTdm5cBnnnEIe2p+GA4nP2DyybmtIR/wttJGMs6Bmz0bXO6AfFdhcGKbzwT2qEGRX5drQj0qUI+gLSZ42/9DsGN7kr2gRDpXG2ATx+c4H2XvR3fqS1cyFq+ZmezK4l32BH/KjQMR1zfgeX2Ky46YxOLQn84PvWILmpzYPLTJ02kXFr0pjofraX2h0E/0Ke2ZBPlOUcaNZOU2dJDYn/B0GVrJ0niopvseYpVXHoTzMPYpr+ReCfAc= admin@Admins-MacBook-Pro.local

new file
data/git-shell-commands/no-interactive-login
@@ -0,0 +1,4 @@
+#!/bin/sh
+printf '%s\n' "Hi $USER! You've successfully authenticated, but I do not"
+printf '%s\n' "provide interactive shell access."
+exit 128

new file
data/ssh_command
@@ -0,0 +1,49 @@
+#!/bin/sh
+
+# set -u # exit on undefined variable
+SSH_ORIGINAL_COMMAND=${SSH_ORIGINAL_COMMAND}
+USERNAME=$1
+
+# If SSH_ORIGINAL_COMMAND is unset, simply kill term.
+if [ -z ${SSH_ORIGINAL_COMMAND+x} ]; then
+  printf '%s\n' "Hi $USER! You've successfully authenticated, but I do not"
+  printf '%s\n' "provide interactive shell access."
+  exit 128
+fi
+
+RES_JSON=$(/usr/bin/ssh_command_node "${USERNAME}")
+EXIT=$?
+
+COMMAND=$(echo "$RES_JSON" | jq -r '.command')
+AUTH_MODE=$(echo "$RES_JSON" | jq -r '.authMode')
+GIT_REPO_DIR=$(echo "$RES_JSON" | jq -r '.gitRepositoryDir')
+
+echo "AUTH_MODE: ${AUTH_MODE}" >> /home/git/ssh_commands.log
+echo "GIT_REPO_DIR: ${GIT_REPO_DIR}" >> /home/git/ssh_commands.log
+
+echo "ssh_command_node stdout: ${RES_JSON}" >> /home/git/ssh_commands.log
+echo "ssh_command_node exit code: ${EXIT}" >> /home/git/ssh_commands.log
+
+if [ "$EXIT" = "0" ]; then
+  $COMMAND $GIT_REPO_DIR;
+  RESULT=$?
+
+  echo "result => ${RESULT}" >> /home/git/ssh_commands.log
+  exit $?
+else
+  echo "Could not complete request."
+  exit 1
+fi
+
+# If we should reject:
+
+
+# Assuming bash will only execute the first command in the string
+# TODO See this https://unix.stackexchange.com/a/444949/309572
+# {
+#   $SSH_ORIGINAL_COMMAND
+#   exit $?
+# } || { # catch
+#   echo "Could not complete request."
+#   exit 1
+# }

new file
data/ssh_command_node
@@ -0,0 +1,101 @@
+#!/usr/bin/node
+
+const fs = require("fs");
+const cp = require("child_process");
+
+async function main(args, sshOriginalCommand) {
+  const [_, __, username] = args;
+
+  if (username == null || username.trim() === "") {
+    console.log(
+      `Hi ${process.env.USER}!\nLooks like we could not find your username.`
+    );
+    process.exit(128);
+  }
+
+  if (sshOriginalCommand == null) {
+    console.log(
+      `Hi ${process.env.USER}!\nYou've successfully authenticated, but I do not provide interactive shell access.`
+    );
+    process.exit(128);
+  }
+
+  const authorizedKeysBuffer = fs.readFileSync(
+    "/home/git/.ssh/authorized_keys",
+    { encoding: "utf8" }
+  );
+
+  const authKeys = authorizedKeysBuffer
+    .split("\n")
+    .map((line) =>
+      line.startsWith("#")
+        ? { type: "comment", text: line }
+        : line.trim() !== ""
+        ? { type: "key", text: line }
+        : null
+    )
+    .filter((x) => x != null && x.type === "key");
+
+  const pk = authKeys.find((key) =>
+    key.text.includes(`command="ssh_command ${username}"`)
+  )?.text;
+
+  const sshRsaIndex = pk.indexOf("ssh-rsa");
+  const publicKey = pk.substring(sshRsaIndex);
+
+  const [command, repoSlug] = sshOriginalCommand
+    .split(" ")
+    .map((part) => part.replace(/\'/g, "").trim());
+
+  fs.appendFileSync(
+    "/home/git/ssh_commands.log",
+    `username: ${username}\npublicKey: ${publicKey}\ncommand: ${command}\nrepoSlug: ${repoSlug}\n-----------\n\n`,
+    { encoding: "utf8" }
+  );
+
+  // console.log(
+  //   `username: ${username}\npublicKey: ${publicKey}\ncommand: ${command}\nrepoSlug: ${repoSlug}\n`
+  // );
+
+  const res = await fetch(`http://localhost:1337/_ssh/auth`, {
+    method: "POST",
+    body: JSON.stringify({
+      command,
+      repoSlug,
+      username,
+      publicKey,
+    }),
+  });
+
+  if (res.ok === false) {
+    const text = await res.text();
+    fs.appendFileSync(
+      "/home/git/ssh_commands.log",
+      `${res.status}: ${res.statusText} - ${text}\n-----------\n\n`,
+      { encoding: "utf8" }
+    );
+    console.log("Forbidden access.");
+    process.exit(128);
+    return;
+  }
+
+  const json = await res.json();
+
+  console.log(JSON.stringify(json));
+
+  fs.appendFileSync(
+    "/home/git/ssh_commands.log",
+    `${JSON.stringify(json, null, 2)}\n-----------\n\n`,
+    { encoding: "utf8" }
+  );
+
+  if (json.success === false) {
+    console.log("Forbidden access.");
+    process.exit(128);
+  }
+
+  // success!
+  process.exit(0);
+}
+
+main(process.argv, process.env.SSH_ORIGINAL_COMMAND);

new file
db/migrations/20231218001445_add_user_ssh_keys_and_user_ssh_key_model/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "UserSSHKey" (
+    "id" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "userId" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "key" TEXT NOT NULL,
+    "revoked" BOOLEAN NOT NULL DEFAULT false,
+
+    CONSTRAINT "UserSSHKey_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "UserSSHKey" ADD CONSTRAINT "UserSSHKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@@ -11,12 +11,15 @@ datasource db {
 
 model Organization {
   id      String @id @default(cuid())
-  ownerId String
-  slug    String @unique
-
+  
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 
+  slug    String @unique
+
+  owner   User   @relation("ManyOwnedOrganizationsToOneOwnerUser", fields: [ownerId], references: [id])
+  ownerId String
+
   kind        OrganizationKind   @default(PERSONAL)
   visibility  ResourceVisibility @default(PRIVATE)
   avatarUri   String?

...
@@ -24,7 +27,6 @@ model Organization {
   websiteUrl  String?
 
   memberships  OrganizationMembership[] @relation("OneOrganizationMembershipToOneOrganization")
-  owner        User                     @relation("ManyOwnedOrganizationsToOneOwnerUser", fields: [ownerId], references: [id])
   repositories Repository[]             @relation("ManyRepositoriesToOneOrganization")
 }
 

...
@@ -128,7 +130,6 @@ model Session {
 
 model User {
   id String @id @default(cuid())
-
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 

...
@@ -139,12 +140,28 @@ model User {
   displayName    String?
   avatarUri      String?
 
+  sshKeys UserSSHKey[] @relation("ManyUserSSHKeyToOneUser")
+
   organizations                  Organization[]           @relation("ManyOwnedOrganizationsToOneOwnerUser")
   organizationMemberships        OrganizationMembership[] @relation("OneOrganizationMembershipToOneUser")
   pullRequestsWhereAuthor        PullRequest[]            @relation("OnePullRequestToOneUser")
   pullRequestCommentsWhereAuthor PullRequestComment[]     @relation("OnePullRequestToOnePRCommenterUser")
 }
 
+model UserSSHKey {
+  id String @id @default(cuid())
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  user   User   @relation("ManyUserSSHKeyToOneUser", fields: [userId], references: [id])
+  userId String
+
+  name String
+  key String
+
+  revoked Boolean @default(false)
+}
+
 enum GlobalRole {
   GUEST
   CUSTOMER

@@ -22,9 +22,11 @@ services:
       - db
     ports:
       - 1337:1337
+      - 22:22
     volumes:
       - ./data/gitfoss_repos:/var/lib/gitfoss/repos
-    env_file: .env.local
+      - ./data/gitfoss_repos:/home/git/repos
+    env_file: .env.docker
     # environment:
     #   - COOKIE_NAME=gitfoss_ssid
     #   - COOKIE_SECRET=gitfoss-cookie-secret

@@ -10,13 +10,13 @@
     "postinstall": "patch-package",
     "clean": "rm -rf dist/",
     "generate": "run-s generate:prisma",
-    "generate:prisma": "prisma generate",
+    "generate:prisma": "dotenv -e ./.env.local -- prisma generate",
     "generate:prisma-data-proxy": "prisma generate --data-proxy",
     "gitstamp": "git rev-parse HEAD > .gitstamp",
-    "db:push": "prisma db push --preview-feature",
-    "migrate:dev": "prisma migrate dev",
-    "migrate:deploy": "prisma migrate deploy",
-    "migrate:reset": "prisma migrate reset",
+    "db:push": "dotenv -e ./.env.local -- prisma db push --preview-feature",
+    "migrate:dev": "dotenv -e ./.env.local -- prisma migrate dev",
+    "migrate:deploy": "dotenv -e ./.env.local -- prisma migrate deploy",
+    "migrate:reset": "dotenv -e ./.env.local -- prisma migrate reset",
     "bundle:islands": "NODE_ENV=production bundle-islands",
     "build:ts": "NODE_ENV=production tsc",
     "build": "run-s clean generate build:ts bundle:islands",

...
@@ -35,9 +35,11 @@
     "@fastify/cookie": "6.0.0",
     "@fastify/formbody": "6.0.0",
     "@prisma/client": "^4.9.0",
+    "color": "^4.2.3",
     "cross-fetch": "^3.1.5",
     "cuid": "^2.1.8",
     "diffparser": "^2.0.1",
+    "dotenv-cli": "^7.3.0",
     "dotenv-flow": "^3.2.0",
     "esbuild-plugin-prismjs": "^1.0.8",
     "fastify": "^3.27.4",

...
@@ -63,6 +65,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.0.0-0",
+    "@types/color": "^3.0.6",
     "@types/cuid": "^2.0.1",
     "@types/dotenv-flow": "^3.2.0",
     "@types/fastify-static": "^2.2.1",

@@ -2,19 +2,7 @@
   "name": "GitFOSS",
   "short_name": "GitFOSS",
   "start_url": "/",
-  "icons": [
-    {
-      "src": "/public/assets/social-icon-192.png",
-      "sizes": "192x192",
-      "type": "image/png",
-      "purpose": "any maskable"
-    },
-    {
-      "src": "/public/assets/social-icon.png",
-      "sizes": "512x512",
-      "type": "image/png"
-    }
-  ],
+  "icons": [],
   "display": "standalone",
   "orientation": "portrait",
   "theme_color": "#1B8F97",

types/global/index.d.ts
@@ -8,6 +8,7 @@ import { PrismaClient } from "@prisma/client";
 import type { AppSessionData, AppThemeScheme } from "../../app/types";
 import type { CodeAnalysisServiceAPI } from "../../app/services/codeAnalysis/types";
 import type { CryptoServiceAPI } from "../../app/services/crypto/types";
+import type { GitServerServiceAPI } from "../../services/gitServer/types";
 
 declare module "@ethicdevs/fastify-custom-session" {
   // from app types

...
@@ -20,6 +21,8 @@ declare module "fastify" {
     codeAnalysisService: CodeAnalysisServiceAPI;
     // from crypto plugin
     cryptoService: CryptoServiceAPI;
+    // from server file
+    gitService: GitServerServiceAPI;
     // from prisma plugin
     prisma: PrismaClient;
   }

...
@@ -33,6 +36,8 @@ declare module "fastify" {
     // from crypto plugin
     cryptoService: CryptoServiceAPI;
     // from server file
+    gitService: GitServerServiceAPI;
+    // from server file
     gitStamp: string;
     // from react-monolith: request utility that maps a viewName to its routerPath
     namedViewsPathMap: Record<string, string>;

@@ -926,6 +926,25 @@
     "@types/connect" "*"
     "@types/node" "*"
 
+"@types/color-convert@*":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.3.tgz#e93f5c991eda87a945058b47044f5f0008b0dce9"
+  integrity sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==
+  dependencies:
+    "@types/color-name" "*"
+
+"@types/color-name@*":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.3.tgz#c488ac2e519c9795faa0d54e8156d54e66adc4e6"
+  integrity sha512-87W6MJCKZYDhLAx/J1ikW8niMvmGRyY+rpUxWpL1cO7F8Uu5CHuQoFv+R0/L5pgNdW4jTyda42kv60uwVIPjLw==
+
+"@types/color@^3.0.6":
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/@types/color/-/color-3.0.6.tgz#29c27a99d4de2975e1676712679a0bd7f646a3fb"
+  integrity sha512-NMiNcZFRUAiUUCCf7zkAelY8eV3aKqfbzyFQlXpPIEeoNDbsEHGpb854V3gzTsGKYj830I5zPuOwU/TP5/cW6A==
+  dependencies:
+    "@types/color-convert" "*"
+
 "@types/connect@*":
   version "3.4.35"
   resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"

...
@@ -1538,10 +1557,26 @@ color-name@1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
 
-color-name@~1.1.4:
+color-name@^1.0.0, color-name@~1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
 
+color-string@^1.9.0:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
+  integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
+  dependencies:
+    color-name "^1.0.0"
+    simple-swizzle "^0.2.2"
+
+color@^4.2.3:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a"
+  integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
+  dependencies:
+    color-convert "^2.0.1"
+    color-string "^1.9.0"
+
 combined-stream@^1.0.8:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"

...
@@ -1761,12 +1796,32 @@ domexception@^2.0.1:
   dependencies:
     webidl-conversions "^5.0.0"
 
+dotenv-cli@^7.3.0:
+  version "7.3.0"
+  resolved "https://registry.yarnpkg.com/dotenv-cli/-/dotenv-cli-7.3.0.tgz#21e33e7944713001677658d68856063968edfbd2"
+  integrity sha512-314CA4TyK34YEJ6ntBf80eUY+t1XaFLyem1k9P0sX1gn30qThZ5qZr/ZwE318gEnzyYP9yj9HJk6SqwE0upkfw==
+  dependencies:
+    cross-spawn "^7.0.3"
+    dotenv "^16.3.0"
+    dotenv-expand "^10.0.0"
+    minimist "^1.2.6"
+
+dotenv-expand@^10.0.0:
+  version "10.0.0"
+  resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-10.0.0.tgz#12605d00fb0af6d0a592e6558585784032e4ef37"
+  integrity sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==
+
 dotenv-flow@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/dotenv-flow/-/dotenv-flow-3.2.0.tgz#a5d79dd60ddb6843d457a4874aaf122cf659a8b7"
   dependencies:
     dotenv "^8.0.0"
 
+dotenv@^16.3.0:
+  version "16.3.1"
+  resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e"
+  integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==
+
 dotenv@^8.0.0:
   version "8.6.0"
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"

...
@@ -2670,6 +2725,11 @@ is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
 
+is-arrayish@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
+  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
+
 is-bigint@^1.0.1:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"

...
@@ -4555,6 +4615,13 @@ signal-exit@^3.0.2, signal-exit@^3.0.3:
   version "3.0.7"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
 
+simple-swizzle@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
+  integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==
+  dependencies:
+    is-arrayish "^0.3.1"
+
 sisteransi@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"

GitFOSS - v0.2.0 (#48b426e) - MIT License