feat(ssh,keys,command): compute ssh keys fingerprint on add using ss-keygen@@ -1,5 +1,5 @@
{
- "_generatedAtUnix": 1778839401647,
+ "_generatedAtUnix": 1779047887440,
"_hashAlgorithm": "sha1",
"_version": 2,
"assets": {
@@ -40,7 +40,7 @@
"pathSourceMap": "./public/.islands/PullRequestSourceSelect.bundle.js.map"
},
"RepositoriesList": {
- "hash": "648218c692146b6bff49bf45eb44b2d650a6a2b1",
+ "hash": "42a9c5e680868fed97e448c96287f3fa70935911",
"pathSource": "./app/islands/RepositoriesList.tsx",
"pathBundle": "./public/.islands/RepositoriesList.bundle.js",
"pathSourceMap": "./public/.islands/RepositoriesList.bundle.js.map"
@@ -1,6 +1,6 @@
// 3rd-party
import type { ReqHandler } from "@ethicdevs/react-monolith";
-import { User } from "@prisma/client";
+import { ResourceVisibility, User } from "@prisma/client";
// app
import { AppRoute, AppRouteParams } from "../../routes.defs";
import { makeUsersService } from "../../services/user";
@@ -34,7 +34,14 @@ const getUserDetailsView: ReqHandler<
title: `@${username}`,
currentUser,
user,
- repositories: await usersService.getUserRepositories(user),
+ repositories: (await usersService.getUserRepositories(user)).filter(
+ (repo) => {
+ if (user.id === curr_user_uid) {
+ return true;
+ }
+ return repo.visibility === ResourceVisibility.PUBLIC;
+ },
+ ),
});
};
@@ -8,6 +8,7 @@ import type { Organization, Repository } from "@prisma/client";
import type { WithThemeSchemeProp } from "../types";
import { AppRoute } from "../routes.defs";
import { Card } from "../components/Card.styled";
+import { Chip } from "../components/Chip";
import { Grid } from "../components/Grid";
import { buildRouteLink } from "../utils/shared";
import { breakpoints, NamedColors } from "../utils/style";
@@ -19,23 +20,74 @@ export interface RepositoriesListProps {
const RepositoriesList: ReactIsland<
RepositoriesListProps & WithThemeSchemeProp
> = ({ repositories, themeScheme }) => {
- const selector = ".container";
+ // const selector = ".container";
return (
<>
<style>{`
- .container {
- container-type: size;
- container-name: repositories;
- min-block-size: 210px;
- height: max-content;
+ .cards {
+ display: grid;
+ grid-template-columns: 1fr; /* mobile default (1 column) */
+
+ width: 100%;
+ max-width: 1200px; /* prevents cards becoming too wide when no sidebar */
+
+ margin: 0 auto;
+ box-sizing: border-box;
+ gap: 16px;
+ }
+
+ /* Tablet fallback using container width (if no container queries) */
+ @media (min-width: 600px) {
+ .cards {
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ }
+ }
+
+ /* Desktop: force 3 columns */
+ @media (min-width: 1100px) {
+ .cards {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ }
+
+ /* Container query (preferred) — adapts based on .cards container width, not viewport */
+ @container (min-width: 600px) {
+ .cards {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(300px, 1fr)); // auto-fit
+ }
+ .cards > .card {
+ min-width: 300px;
+ width: 100%;
+ }
+ }
+ @container (min-width: 928px) {
+ .cards {
+ display: flex;
+ justify-content: stretch;
+ align-items: flex-start;
+ }
+ .cards > .card {
+ flex: .33;
+ min-width: 33%;
+ }
+ }
+ @container (min-width: 1100px) {
+ .cards {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ }
+
+ /* Required: enable container for .cards */
+ .cards {
+ container-type: inline-size;
}
.card {
min-width: 300px;
- max-width: calc(33% - 8px);
width: 100%;
flex: 0 0 33%;
- max-height: 200px;
+ max-height: 320px;
min-height: 200px;
gap: 8px;
}
@@ -61,82 +113,13 @@ const RepositoriesList: ReactIsland<
}
}
`}</style>
- <script>{`
- // quick js to set @container height based on number of cards
- function resize() {
- const gap = 4;
- const cardWidth = 300; // match your min-width
- const cardHeight = 200; // use computed or measured height
- const container = document.querySelector(\`${selector}\`);
- const cards = document.querySelectorAll(\`${selector} .card\`);
- if (!container || cards.length === 0) return;
- const containerWidth = container.clientWidth;
- const cols = Math.floor((containerWidth + gap) / (cardWidth + gap));
- const rows = Math.ceil(cards.length / cols);
- let height = (cols * cardHeight);
- let i = 0;
- if (cols === 1) {
- height = rows * cardHeight;
- height += cardHeight;
- i += rows;
- } else {
- height = cols * cardHeight;
- i += 1;
- if (cols == 2) {
- if (cards.length % cols !== 0) {
- height += cardHeight * 2;
- i += 1;
- }
- } else if (cols == 3) {
- if (cards.length % rows !== 0) {
- height += cardHeight;
- height -= cardHeight / 1.2; // why 1.2?
- }
- i += 1;
- } else if (cols == 4) {
- if (cards.length % cols !== 0) {
- height -= cardHeight;
- i += 1;
- }
- }
- }
- height -= ((rows * gap) * i);
- // container.style.height = \`\${height}px\`;
- container.style.minBlockSize = \`\${height}px\`;
- cards.forEach(function (card, index) {
- card.style.height = cardHeight;
- card.style.maxHeight = cardHeight;
- card.style.minHeight = cardHeight;
- });
- console.log({
- containerWidth,
- cols,
- rows,
- height,
- })
- }
- window.addEventListener(\`load\`, function(event) {
- resize();
- });
- window.addEventListener(\`resize\`, function(event) {
- // function debounce(func, wait) {
- // let timeout;
- // return function() {
- // clearTimeout(timeout);
- // timeout = setTimeout(func, wait);
- // };
- // }
- // debounce(resize, 200);
- resize();
- });
- `}</script>
<Grid.Row
fluid
gap={4}
alignItems={"stretch"}
justifyContent={"stretch"}
style={{ marginTop: 16 }}
- className="container"
+ className="cards"
>
{repositories.map((repo) => (
<Card key={repo.id} themeScheme={themeScheme} className="card">
@@ -151,10 +134,10 @@ const RepositoriesList: ReactIsland<
{repo.parentOrg.displayName || repo.parentOrg.slug}
{" / "}
{repo.displayName || repo.slug}
- {" ∙ "}
+ {/*{" ∙ "}
<span style={{ textTransform: "capitalize" }}>
({repo.visibility.toLowerCase()})
- </span>
+ </span>*/}
</a>
</h1>
{repo.isFork && (
@@ -164,7 +147,7 @@ const RepositoriesList: ReactIsland<
)}
</Grid.Row>
<Grid.Col fluid gap={8}>
- <p style={{ margin: 0 }}>{repo.shortDescription}</p>
+ <p style={{ flex: 1, margin: 0 }}>{repo.shortDescription}</p>
{repo.lastPushedAt != null && (
<p
style={{
@@ -176,6 +159,9 @@ const RepositoriesList: ReactIsland<
Last push: {new Date(repo.lastPushedAt).toLocaleString()}
</p>
)}
+ <Chip themeScheme={themeScheme}>
+ {repo.visibility.toLowerCase()}
+ </Chip>
</Grid.Col>
</Card>
))}
@@ -13,9 +13,16 @@ const makeAuthorizationResolver: ServiceMethodFactory<
return async (repoPath, { username, password }) => {
const [orgSlug, repoSlugUnsafe] = repoPath.split("/");
- // password contains the publicKey instead of regular password
- const isPubKeyAuth = repoSlugUnsafe.endsWith(".pub");
+ if (orgSlug == null || orgSlug.trim() === "") {
+ return false;
+ }
+ if (repoSlugUnsafe == null || repoSlugUnsafe.trim() === "") {
+ return false;
+ }
+
+ // if password contains the publicKey instead of regular password
+ const isPubKeyAuth = repoSlugUnsafe.endsWith(".pub");
const repoSlug = repoSlugUnsafe.replace(/\.(git|pub)$/, "");
console.log("AuthorizationResolver called with:", {
@@ -65,45 +72,43 @@ const makeAuthorizationResolver: ServiceMethodFactory<
const repoMembershipsHasReqUser =
org.memberships.find((m) => m.id === user.id) != null;
+ // allow read-only for unlisted users without auth, but write still behind auth.
if (repo.visibility === ResourceVisibility.PUBLIC) {
return true;
- } else {
- // 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,
- revoked: false,
- },
- include: {
- user: true,
- },
- });
-
- return (
- matchingPk != null &&
- matchingPk.user.id === user.id &&
- matchingPk.user.username === username
- );
- }
-
- const hashedPassword = cryptoService.computeHash(password);
- const authed = hashedPassword === user.hashedPassword;
-
- console.log("git.authorizationResolver:", authed);
-
- console.log("git.authorizationResolver:", authed, username);
-
- return authed;
}
+
+ 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,
+ revoked: false,
+ },
+ include: {
+ user: true,
+ },
+ });
+
+ return (
+ matchingPk != null &&
+ matchingPk.user.id === user.id &&
+ matchingPk.user.username === username
+ );
+ }
+
+ const hashedPassword = cryptoService.computeHash(password);
+ const authed = hashedPassword === user.hashedPassword;
+
+ console.log("git.authorizationResolver:", authed, username);
+
+ return authed;
};
};
@@ -44,6 +44,35 @@ const makeGetRepositoryHead: ServiceMethodFactory<
throw new Error(`Could not find a valid git repository at: ${repoPath}`);
}
+ // could also set default branch: git symbolic-ref HEAD refs/heads/develop
+ // const gitSymbolicRefProcess = spawn(
+ // "git",
+ // ["symbolic-ref", "--short", "HEAD"],
+ // {
+ // cwd: repoPath,
+ // env: {
+ // LANG: "C",
+ // },
+ // },
+ // );
+
+ // // resolve default ref using git symbolic-ref
+ // // so default refs like "HEAD" are resolved to branch names
+ // const gitSymbolicRefResult = await new Promise<string>(
+ // (resolve, reject) => {
+ // let buffer = [] as string[];
+ // gitSymbolicRefProcess.stdout.on("data", (data) => buffer.push(data));
+ // gitSymbolicRefProcess.stderr.on("data", (data) => {
+ // reject(new Error(Buffer.from(data).toString("utf-8")));
+ // });
+ // gitSymbolicRefProcess.stdout.on("close", () => {
+ // resolve(buffer.join(""));
+ // });
+ // },
+ // );
+ //
+ // ref = gitSymbolicRefResult;
+
if (ref === Const.DEFAULT_HEAD_REF) {
ref = Const.PRIMARY_BRANCH_REF;
}
@@ -1,11 +1,13 @@
// std
import fs from "fs";
+import { spawn } from "node:child_process";
// 1st-party
import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
// generated via script[generate:prisma]
import { User } from "@prisma/client";
// app
import type { UsersServiceDeps } from "./types";
+import { spawnConcatChunks } from "../../utils/server";
const SSH_RSA_KEY_REGEXP =
/^ssh-rsa AAAA[0-9A-Za-z+\/]+[=]{0,3} ([^@]+@[^@]+)$/i;
@@ -19,7 +21,7 @@ const addUserSSHKey: ServiceMethodFactory<
// 0. Validate key is actually a ssh-rsa key
if (key.match(SSH_RSA_KEY_REGEXP) == null) {
throw new Error(
- "Invalid public key. Please provide a valid SSH RSA public key."
+ "Invalid public key. Please provide a valid SSH RSA public key.",
);
}
@@ -32,20 +34,49 @@ const addUserSSHKey: ServiceMethodFactory<
// 1. Check if public key is already registered
if (existingKey != null) {
throw new Error(
- "Public key is already registered. Please use another one."
+ "Public key is already registered. Please use another one.",
);
}
- // 2. Add key to database
+ // 2. Compute key fingerprints (using ssh-keygen)
+ const fingerprintSHA256Process = spawn("ssh-keygen", ["-l", "-f", "-"], {
+ env: { LANG: "C" },
+ });
+
+ // pipe key to ssh-keygen stdin
+ fingerprintSHA256Process.stdin.write(key);
+
+ const fingerprintMD5Process = spawn(
+ "ssh-keygen",
+ ["-lf", "-E", "md5", "-"],
+ { env: { LANG: "C" } },
+ );
+
+ // pipe key to ssh-keygen stdin
+ fingerprintMD5Process.stdin.write(key);
+
+ const fingerprintSHA256 = await spawnConcatChunks(fingerprintSHA256Process);
+ const fingerprintMD5 = await spawnConcatChunks(fingerprintMD5Process);
+
+ console.log("fingerprints:", {
+ sha256: fingerprintSHA256,
+ md5: fingerprintMD5,
+ });
+
+ // 3. Add key to database
const userKey = await request.prisma.userSSHKey.create({
data: {
name: name,
- key: key,
user: {
connect: {
id: user.id,
},
},
+ key: key,
+ key_fingerprint: fingerprintSHA256,
+ // key_fingerprint_md5: fingerprintMD5,
+ revoked: false,
+ lastUsedAt: null,
},
});
@@ -54,6 +85,7 @@ const addUserSSHKey: ServiceMethodFactory<
}
let line = "";
+ line += `environment="KEY=${key},KEY_FINGERPRINT=${fingerprint}",`;
line += `command="ssh_command ${user.username}",`;
line += "no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ";
line += `${key}\n`;
@@ -9,3 +9,4 @@ export { localAppDomainPreHandler } from "./localAppDomainPreHandler";
export { makeRequestHandler } from "./makeRequestHandler";
export { sessionSetupPreHandler } from "./sessionSetupPreHandler";
export { loadRepositoryCounters } from "./loadRepositoryCounters";
+export { spawnConcatChunks } from "./spawnConcatChunks";
@@ -0,0 +1,16 @@
+import { ChildProcessWithoutNullStreams } from "node:child_process";
+
+export function spawnConcatChunks(
+ process: ChildProcessWithoutNullStreams,
+): Promise<string> {
+ return new Promise<string>((resolve, reject) => {
+ let buffer = [] as string[];
+ process.stdout.on("data", (data) => buffer.push(data));
+ process.stderr.on("data", (data) => {
+ reject(new Error(Buffer.from(data).toString("utf-8")));
+ });
+ process.stdout.on("close", () => {
+ resolve(buffer.join(""));
+ });
+ });
+}
@@ -55,7 +55,7 @@ end
def sideband_println(msg : String)
txt = "#{SIDEBAND_PREFIX}#{msg.ends_with?("\n") ? msg : msg + "\n"}"
- STDERR.puts txt
+ # STDERR.puts txt
write_to_file txt
end
{repo.shortDescription}
"},{"type":"add","add":true,"newLine":150,"position":189,"content":"+{repo.shortDescription}
"},{"type":"normal","normal":true,"oldLine":168,"newLine":151,"position":190,"content":" {repo.lastPushedAt != null && ("},{"type":"normal","normal":true,"oldLine":169,"newLine":152,"position":191,"content":""},{"type":"normal","normal":true,"oldLine":178,"newLine":161,"position":195,"content":" )}"},{"type":"add","add":true,"newLine":162,"position":196,"content":"+
{repo.shortDescription}
\n+{repo.shortDescription}
\n {repo.lastPushedAt != null && (\n\n )}\n+