- {snippets.map(snippet => (
-
+ {paginatedSnippets.map(snippet => (
+
-
{snippet.name}
+
+
{snippet.name}
+ {snippet.sourceId && (
+
+ )}
+
{snippet.description &&
{snippet.description}
}
{snippet.command}
-
-
-
-
+ {!isReadOnly && (
+
+
+
+
+ )}
))}
+
+ {snippets.length > itemsPerPage && (
+
+
+ {t('audit.pagination.showing', {
+ start: ((currentPage - 1) * itemsPerPage) + 1,
+ end: Math.min(currentPage * itemsPerPage, snippets.length),
+ total: snippets.length
+ })}
+
+
+
+
+
+ )}
);
};
\ No newline at end of file
diff --git a/client/src/pages/Snippets/components/SnippetsList/styles.sass b/client/src/pages/Snippets/components/SnippetsList/styles.sass
index 87599c220..73411ec78 100644
--- a/client/src/pages/Snippets/components/SnippetsList/styles.sass
+++ b/client/src/pages/Snippets/components/SnippetsList/styles.sass
@@ -1,90 +1,215 @@
@use "@/common/styles/colors"
-.snippets-list
- width: 100%
- padding: 2rem
- box-sizing: border-box
- overflow-x: hidden
-
- .snippet-grid
- display: grid
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr))
+.snippets-list-container
+ display: flex
+ flex-direction: column
+ gap: 1rem
+
+.snippet-grid
+ display: grid
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr))
+ gap: 1rem
+ padding: 0
+ flex: 1
+
+ @media (max-width: 768px)
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))
+ gap: 0.875rem
+
+ @media (max-width: 480px)
+ grid-template-columns: 1fr
gap: 0.75rem
- .snippet-item
- background-color: colors.$lighter-background
- border-radius: 0.75rem
+.snippet-item
+ background-color: colors.$lighter-background
+ border-radius: 0.75rem
+ padding: 1.25rem
+ position: relative
+ border: 1px solid colors.$dark-gray
+ transition: border-color 0.15s ease
+ display: flex
+ flex-direction: column
+ height: fit-content
+
+ @media (max-width: 768px)
padding: 1rem
- position: relative
- border: 1px solid colors.$dark-gray
- .snippet-info
+ &:hover
+ border-color: rgba(255, 255, 255, 0.3)
+
+ .snippet-info
+ flex: 1
+
+ .snippet-header
+ display: flex
+ align-items: center
+ gap: 0.5rem
+ margin-bottom: 0.5rem
+ padding-right: 4.5rem
+
h3
margin: 0
- color: colors.$white
- font-size: 0.95rem
- font-weight: 600
-
- p
- margin: 0.5rem 0
- color: colors.$subtext
- font-size: 0.85rem
- line-height: 1.4
-
- .snippet-command
- margin: 0.75rem 0 0
- padding: 0.625rem
- background-color: colors.$darker-gray
- border: 1px solid colors.$gray
- border-radius: 0.4rem
- color: colors.$white
- font-family: monospace
- font-size: 0.8rem
- white-space: pre-wrap
- word-break: break-all
- max-height: 120px
- overflow: hidden
+ padding-right: 0
+
+ .source-badge
+ width: 1rem
+ height: 1rem
+ color: colors.$primary
+ flex-shrink: 0
+
+ h3
+ margin: 0
+ color: colors.$white
+ font-size: 1rem
+ font-weight: 700
+ line-height: 1.3
+ letter-spacing: -0.01em
+ padding-right: 4.5rem
+
+ p
+ margin: 0 0 0.75rem 0
+ color: colors.$subtext
+ font-size: 0.85rem
+ line-height: 1.5
+ padding-right: 4.5rem
- .snippet-actions
+ .snippet-command
+ margin: 0.875rem 0 0
+ padding: 0.75rem
+ background-color: rgba(0, 0, 0, 0.2)
+ border: 1px solid colors.$gray
+ border-radius: 0.5rem
+ color: colors.$white
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace
+ font-size: 0.8rem
+ white-space: pre-wrap
+ word-break: break-word
+ max-height: 120px
+ overflow-y: auto
+ scrollbar-width: thin
+ scrollbar-color: colors.$gray transparent
+
+ &::-webkit-scrollbar
+ width: 4px
+ height: 4px
+
+ &::-webkit-scrollbar-track
+ background: transparent
+
+ &::-webkit-scrollbar-thumb
+ background-color: colors.$gray
+ border-radius: 10px
+
+ .snippet-actions
+ display: flex
+ gap: 0.375rem
+ position: absolute
+ top: 1rem
+ right: 1rem
+
+ .action-button
+ width: 2rem
+ height: 2rem
+ border-radius: 0.5rem
+ background-color: transparent
+ border: 1px solid transparent
display: flex
- gap: 0.375rem
- position: absolute
- top: 0.75rem
- right: 0.75rem
-
- .action-button
- width: 2rem
- height: 2rem
- border-radius: 0.5rem
- background-color: transparent
- border: none
- display: flex
- justify-content: center
- align-items: center
- cursor: pointer
- color: colors.$subtext
- transition: background-color 0.1s ease
+ justify-content: center
+ align-items: center
+ cursor: pointer
+ color: colors.$subtext
+ transition: all 0.1s ease
+
+ svg
+ width: 1rem
+ height: 1rem
+ opacity: 0.9
+
+ &:hover
+ background-color: colors.$gray
+ border-color: colors.$gray
+ color: colors.$white
svg
- width: 1.125rem
- height: 1.125rem
- opacity: 0.9
-
- &:hover
- background-color: colors.$gray
- color: colors.$white
-
- &.delete:hover
- background-color: colors.$error-opacity
- color: colors.$error
+ opacity: 1
+
+ &.delete:hover
+ background-color: colors.$error-opacity
+ border-color: colors.$error
+ color: colors.$error
+
+.pagination
+ display: flex
+ align-items: center
+ justify-content: space-between
+ padding: 0.875rem 1rem
+ margin-top: 1rem
+ background-color: colors.$lighter-background
+ border: 1px solid colors.$dark-gray
+ border-radius: 0.75rem
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2)
+
+ @media (max-width: 768px)
+ padding: 0.75rem 0.875rem
+ flex-direction: column
+ gap: 0.75rem
+
+ @media (max-width: 480px)
+ padding: 0.625rem 0.75rem
+
+ .pagination-info
+ color: colors.$subtext
+ font-size: 0.875rem
+ font-weight: 500
+
+ @media (max-width: 768px)
+ font-size: 0.8rem
+
+ .pagination-controls
+ display: flex
+ align-items: center
+ gap: 0.875rem
+
+ @media (max-width: 768px)
+ gap: 0.75rem
+
+ @media (max-width: 480px)
+ gap: 0.625rem
+ flex-wrap: wrap
+ justify-content: center
+
+ .page-info
+ padding: 0.5rem 0.875rem
+ background-color: colors.$dark-gray
+ border-radius: 0.5rem
+ color: colors.$white
+ font-weight: 600
+ font-size: 0.875rem
+ min-width: 80px
+ text-align: center
+
+ @media (max-width: 768px)
+ font-size: 0.8rem
+ padding: 0.4rem 0.75rem
+ min-width: 70px
+
+ @media (max-width: 480px)
+ font-size: 0.8rem
+ padding: 0.4rem 0.625rem
+ min-width: 60px
.empty-snippets
display: flex
- justify-content: center
+ flex-direction: column
align-items: center
- height: 200px
- width: 100%
+ justify-content: center
+ padding: 3rem
+ color: colors.$subtext
+ text-align: center
+ flex: 1
p
+ margin: 0
color: colors.$subtext
- font-size: 0.9rem
\ No newline at end of file
+ font-size: 0.95rem
+ font-weight: 500
\ No newline at end of file
diff --git a/client/src/pages/Snippets/styles.sass b/client/src/pages/Snippets/styles.sass
index 406980cdb..fd6a9e613 100644
--- a/client/src/pages/Snippets/styles.sass
+++ b/client/src/pages/Snippets/styles.sass
@@ -3,5 +3,116 @@
.snippets-page
display: flex
flex-direction: column
- height: 100vh
- background-color: colors.$background
\ No newline at end of file
+ height: 100%
+ background-color: colors.$background
+
+ .snippets-content-wrapper
+ flex: 1
+ overflow-y: auto
+ overflow-x: hidden
+ display: flex
+ flex-direction: column
+
+ .snippets-controls
+ display: flex
+ align-items: center
+ justify-content: space-between
+ gap: 1.5rem
+ padding: 1rem 2rem
+ flex-shrink: 0
+
+ @media (max-width: 768px)
+ padding: 0.875rem 1.5rem
+ gap: 1rem
+
+ @media (max-width: 480px)
+ padding: 0.75rem 1rem
+ flex-direction: column
+ align-items: stretch
+ gap: 0.75rem
+
+ .snippets-tabs
+ display: flex
+ gap: 0.75rem
+
+ @media (max-width: 768px)
+ gap: 0.625rem
+
+ @media (max-width: 480px)
+ gap: 0.5rem
+ flex: 1
+
+ .tabs-item
+ padding: 0.625rem 1rem
+ cursor: pointer
+ border-radius: 0.5rem
+ transition: background-color 0.1s ease
+ user-select: none
+ border: 1px solid transparent
+
+ @media (max-width: 768px)
+ padding: 0.5rem 0.875rem
+
+ @media (max-width: 480px)
+ padding: 0.5rem 0.75rem
+ flex: 1
+ text-align: center
+
+ h3
+ margin: 0
+ font-size: 0.9rem
+ font-weight: 500
+ color: colors.$subtext
+ transition: color 0.1s ease
+
+ @media (max-width: 768px)
+ font-size: 0.85rem
+
+ @media (max-width: 480px)
+ font-size: 0.8rem
+
+ &:hover:not(.tabs-item-active)
+ background-color: colors.$dark-gray
+
+ h3
+ color: colors.$white
+
+ &.tabs-item-active
+ background-color: colors.$gray
+ border-color: colors.$gray
+
+ h3
+ color: colors.$white
+ font-weight: 600
+
+ .organization-selector
+ width: 250px
+
+ @media (max-width: 480px)
+ width: 100%
+
+ .snippets-content
+ flex: 1
+ padding: 0 2rem 2rem
+ display: flex
+ flex-direction: column
+ overflow-y: auto
+ min-height: 0
+ scrollbar-width: thin
+ scrollbar-color: colors.$gray transparent
+
+ &::-webkit-scrollbar
+ width: 6px
+
+ &::-webkit-scrollbar-track
+ background: transparent
+
+ &::-webkit-scrollbar-thumb
+ background-color: colors.$gray
+ border-radius: 10px
+
+ @media (max-width: 768px)
+ padding: 0 1.5rem 1.5rem
+
+ @media (max-width: 480px)
+ padding: 0 1rem 1rem
\ No newline at end of file
diff --git a/client/yarn.lock b/client/yarn.lock
index 765a7140c..505b6d82b 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -164,7 +164,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/runtime@^7.18.6", "@babel/runtime@^7.23.2", "@babel/runtime@^7.27.6", "@babel/runtime@^7.28.4", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.23.2", "@babel/runtime@^7.27.6", "@babel/runtime@^7.28.4", "@babel/runtime@^7.9.2":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
@@ -254,90 +254,6 @@
resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-2.6.0.tgz#82f10cbd2eff47b1e9196967749b26f916b808e8"
integrity sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg==
-"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.20.0":
- version "6.20.0"
- resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz#db818c12dce892a93fb8abadc2426febb002f8c1"
- integrity sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==
- dependencies:
- "@codemirror/language" "^6.0.0"
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.17.0"
- "@lezer/common" "^1.0.0"
-
-"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0":
- version "6.8.1"
- resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.8.1.tgz#639f5559d2f33f2582a2429c58cb0c1b925c7a30"
- integrity sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==
- dependencies:
- "@codemirror/language" "^6.0.0"
- "@codemirror/state" "^6.4.0"
- "@codemirror/view" "^6.27.0"
- "@lezer/common" "^1.1.0"
-
-"@codemirror/language@^6.0.0", "@codemirror/language@^6.11.3":
- version "6.11.3"
- resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.11.3.tgz#8e6632df566a7ed13a1bd307f9837765bb1abfdd"
- integrity sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==
- dependencies:
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.23.0"
- "@lezer/common" "^1.1.0"
- "@lezer/highlight" "^1.0.0"
- "@lezer/lr" "^1.0.0"
- style-mod "^4.0.0"
-
-"@codemirror/legacy-modes@^6.5.2":
- version "6.5.2"
- resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz#7e2976c79007cd3fa9ed8a1d690892184a7f5ecf"
- integrity sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==
- dependencies:
- "@codemirror/language" "^6.0.0"
-
-"@codemirror/lint@^6.0.0":
- version "6.8.5"
- resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.5.tgz#9edaa808e764e28e07665b015951934c8ec3a418"
- integrity sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==
- dependencies:
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.35.0"
- crelt "^1.0.5"
-
-"@codemirror/search@^6.0.0":
- version "6.5.11"
- resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.11.tgz#a324ffee36e032b7f67aa31c4fb9f3e6f9f3ed63"
- integrity sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==
- dependencies:
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.0.0"
- crelt "^1.0.5"
-
-"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0":
- version "6.5.2"
- resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.2.tgz#8eca3a64212a83367dc85475b7d78d5c9b7076c6"
- integrity sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==
- dependencies:
- "@marijn/find-cluster-break" "^1.0.0"
-
-"@codemirror/theme-one-dark@^6.0.0":
- version "6.1.3"
- resolved "https://registry.yarnpkg.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz#1dbb73f6e73c53c12ad2aed9f48c263c4e63ea37"
- integrity sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==
- dependencies:
- "@codemirror/language" "^6.0.0"
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.0.0"
- "@lezer/highlight" "^1.0.0"
-
-"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0":
- version "6.38.0"
- resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.38.0.tgz#4486062b791a4247793e0953e05ae71a9e172217"
- integrity sha512-yvSchUwHOdupXkd7xJ0ob36jdsSR/I+/C+VbY0ffBiL5NiSTEBDfB1ZGWbbIlDd5xgdUkody+lukAdOxYrOBeg==
- dependencies:
- "@codemirror/state" "^6.5.0"
- crelt "^1.0.6"
- style-mod "^4.1.0"
- w3c-keyname "^2.2.4"
-
"@esbuild/aix-ppc64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
@@ -660,30 +576,6 @@
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
-"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.3.0":
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.3.0.tgz#123427ec4c53c2c8367415b4441e555b4f85c696"
- integrity sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==
-
-"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.2.3":
- version "1.2.3"
- resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.3.tgz#a20f324b71148a2ea9ba6ff42e58bbfaec702857"
- integrity sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==
- dependencies:
- "@lezer/common" "^1.3.0"
-
-"@lezer/lr@^1.0.0":
- version "1.4.2"
- resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.2.tgz#931ea3dea8e9de84e90781001dae30dea9ff1727"
- integrity sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==
- dependencies:
- "@lezer/common" "^1.0.0"
-
-"@marijn/find-cluster-break@^1.0.0":
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"
- integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==
-
"@mdi/js@^7.4.47":
version "7.4.47"
resolved "https://registry.yarnpkg.com/@mdi/js/-/js-7.4.47.tgz#7d8a4edc9631bffeed80d1ec784f9beae559a76a"
@@ -696,6 +588,20 @@
dependencies:
prop-types "^15.7.2"
+"@monaco-editor/loader@^1.5.0":
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.7.0.tgz#967aaa4601b19e913627688dfe8159d57549e793"
+ integrity sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==
+ dependencies:
+ state-local "^1.0.6"
+
+"@monaco-editor/react@^4.7.0":
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.7.0.tgz#35a1ec01bfe729f38bfc025df7b7bac145602a60"
+ integrity sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==
+ dependencies:
+ "@monaco-editor/loader" "^1.5.0"
+
"@parcel/watcher-android-arm64@2.5.1":
version "2.5.1"
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1"
@@ -960,46 +866,10 @@
dependencies:
csstype "^3.2.2"
-"@uiw/codemirror-extensions-basic-setup@4.25.3":
- version "4.25.3"
- resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.3.tgz#6fb28745e7012bfcad0dc5103119487e40a744bc"
- integrity sha512-F1doRyD50CWScwGHG2bBUtUpwnOv/zqSnzkZqJcX5YAHQx6Z1CuX8jdnFMH6qktRrPU1tfpNYftTWu3QIoHiMA==
- dependencies:
- "@codemirror/autocomplete" "^6.0.0"
- "@codemirror/commands" "^6.0.0"
- "@codemirror/language" "^6.0.0"
- "@codemirror/lint" "^6.0.0"
- "@codemirror/search" "^6.0.0"
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.0.0"
-
-"@uiw/codemirror-theme-github@^4.25.3":
- version "4.25.3"
- resolved "https://registry.yarnpkg.com/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.3.tgz#2dc0b422173baa3f6db697726dd90911653055e4"
- integrity sha512-KdmcO9VicsBgsDErNrNBqwMuTbJRIpeMl9oIjmrNx2iEfIDSOMBIKlX+BkgwTAU+VmhqYY/68/kmF1K8z2FxrQ==
- dependencies:
- "@uiw/codemirror-themes" "4.25.3"
-
-"@uiw/codemirror-themes@4.25.3":
- version "4.25.3"
- resolved "https://registry.yarnpkg.com/@uiw/codemirror-themes/-/codemirror-themes-4.25.3.tgz#2990b0fc396574914e8bf87e54464079d71b009c"
- integrity sha512-k7/B7Vf4jU/WcdewgJWP9tMFxbjB6UpUymZ3fx/TsbGwt2JXAouw0uyqCn1RlYBfr7YQnvEs3Ju9ECkd2sKzdg==
- dependencies:
- "@codemirror/language" "^6.0.0"
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.0.0"
-
-"@uiw/react-codemirror@^4.25.3":
- version "4.25.3"
- resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.25.3.tgz#dd61549051d4398068f087858b39f3fc988c7537"
- integrity sha512-1wtBZTXPIp8u6F/xjHvsUAYlEeF5Dic4xZBnqJyLzv7o7GjGYEUfSz9Z7bo9aK9GAx2uojG/AuBMfhA4uhvIVQ==
- dependencies:
- "@babel/runtime" "^7.18.6"
- "@codemirror/commands" "^6.1.0"
- "@codemirror/state" "^6.1.1"
- "@codemirror/theme-one-dark" "^6.0.0"
- "@uiw/codemirror-extensions-basic-setup" "4.25.3"
- codemirror "^6.0.0"
+"@types/trusted-types@^2.0.7":
+ version "2.0.7"
+ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
+ integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@vitejs/plugin-react@^5.1.1":
version "5.1.1"
@@ -1238,19 +1108,6 @@ chokidar@^4.0.0:
dependencies:
readdirp "^4.0.1"
-codemirror@^6.0.0:
- version "6.0.2"
- resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.2.tgz#4d3fea1ad60b6753f97ca835f2f48c6936a8946e"
- integrity sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==
- dependencies:
- "@codemirror/autocomplete" "^6.0.0"
- "@codemirror/commands" "^6.0.0"
- "@codemirror/language" "^6.0.0"
- "@codemirror/lint" "^6.0.0"
- "@codemirror/search" "^6.0.0"
- "@codemirror/state" "^6.0.0"
- "@codemirror/view" "^6.0.0"
-
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@@ -1283,11 +1140,6 @@ cookie@^1.0.1:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.0.2.tgz#27360701532116bd3f1f9416929d176afe1e4610"
integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==
-crelt@^1.0.5, crelt@^1.0.6:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
- integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
-
cross-fetch@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983"
@@ -1392,6 +1244,13 @@ doctrine@^2.1.0:
dependencies:
esutils "^2.0.2"
+dompurify@3.2.7:
+ version "3.2.7"
+ resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.7.tgz#721d63913db5111dd6dfda8d3a748cfd7982d44a"
+ integrity sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==
+ optionalDependencies:
+ "@types/trusted-types" "^2.0.7"
+
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@@ -2299,6 +2158,11 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
+marked@14.0.0:
+ version "14.0.0"
+ resolved "https://registry.yarnpkg.com/marked/-/marked-14.0.0.tgz#79a1477358a59e0660276f8fec76de2c33f35d83"
+ integrity sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==
+
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
@@ -2319,6 +2183,14 @@ minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
+monaco-editor@^0.55.1:
+ version "0.55.1"
+ resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.55.1.tgz#e74c6fe5a6bf985b817d2de3eb88d56afc494a1b"
+ integrity sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==
+ dependencies:
+ dompurify "3.2.7"
+ marked "14.0.0"
+
ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@@ -2954,6 +2826,11 @@ simple-icons@^15.21.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+state-local@^1.0.6:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5"
+ integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
+
stop-iteration-iterator@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad"
@@ -3026,11 +2903,6 @@ strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
-style-mod@^4.0.0, style-mod@^4.1.0:
- version "4.1.2"
- resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67"
- integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==
-
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
@@ -3207,11 +3079,6 @@ void-elements@3.1.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
-w3c-keyname@^2.2.4:
- version "2.2.8"
- resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
- integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
-
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
diff --git a/docs/index.md b/docs/index.md
index dae5bb641..64fc79616 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -32,9 +32,9 @@ features:
- icon: ✂️
title: Snippets
details: Create and manage snippets for quick access to commands.
- - icon: 📦
- title: Apps
- details: Automate app installations and configurations with apps.
+ - icon: 📜
+ title: Scripts
+ details: Automate repetitive tasks with customizable scripts.
---
diff --git a/server/controllers/appSource.js b/server/controllers/appSource.js
deleted file mode 100644
index b4d35b17b..000000000
--- a/server/controllers/appSource.js
+++ /dev/null
@@ -1,179 +0,0 @@
-const AppSource = require("../models/AppSource");
-const logger = require("../utils/logger");
-const fs = require("fs");
-const axios = require("axios");
-const decompress = require("decompress");
-const path = require("path");
-const yaml = require("js-yaml");
-const { appObject } = require("../validations/appSource");
-const { refreshScripts } = require("./script");
-
-let apps = [];
-let refreshTimer;
-
-const OFFICIAL_SOURCE = "https://apps.nexterm.dev/official.zip";
-
-module.exports.createAppSource = async configuration => {
- const appSource = await AppSource.findOne({ where: { name: configuration.name } });
-
- if (appSource !== null)
- return { code: 101, message: "This app source already exists" };
-
- await AppSource.create(configuration);
-
- this.refreshAppSources();
-};
-
-module.exports.getAppSources = async () => {
- return await AppSource.findAll();
-};
-
-module.exports.getAppSource = async name => {
- return await AppSource.findOne({ where: { name } });
-};
-
-module.exports.updateAppUrl = async (name, url) => {
- const appSource = await AppSource.findOne({ where: { name } });
-
- if (appSource === null)
- return { code: 102, message: "This app source does not exist" };
-
- await AppSource.update({ url }, { where: { name } });
-};
-
-module.exports.deleteAppSource = async name => {
- const appSource = await AppSource.findOne({ where: { name } });
-
- if (appSource === null) {
- return { code: 102, message: "This app source does not exist" };
- }
-
- if (fs.existsSync(process.cwd() + "/data/sources/" + appSource.name))
- fs.rmSync(process.cwd() + "/data/sources/" + appSource.name, { recursive: true });
-
- await AppSource.destroy({ where: { name } });
-
- this.refreshAppSources();
-};
-
-const downloadAppSource = async (name, url) => {
- const { data: buffer } = await axios.get(url, { responseType: "arraybuffer" });
- fs.writeFileSync(process.cwd() + `/data/sources/${name}.zip`, buffer);
-
- return `${process.cwd()}/data/sources/${name}.zip`;
-};
-
-const extractNextermFiles = async (zipFilePath, outputDir) => {
- await decompress(zipFilePath, outputDir, {
- filter: file => file.path.endsWith(".nexterm.yml") || file.path.endsWith(".nexterm.sh"),
- map: file => {
- file.path = path.basename(file.path);
- return file;
- },
- });
-};
-
-const parseAppFile = async (name, sourceFile) => {
- const appFileContent = fs.readFileSync(sourceFile, "utf8");
- const parsedYaml = yaml.load(appFileContent);
-
- if (parsedYaml && parsedYaml["x-nexterm"]) {
- return parsedYaml["x-nexterm"];
- } else {
- throw new Error("x-nexterm not found in the YAML file");
- }
-};
-
-const parseAppSource = async (name, sourceDir) => {
- const files = fs.readdirSync(sourceDir);
-
- for (const file of files) {
- if (file.endsWith(".nexterm.yml")) {
- const appItem = await parseAppFile(name, sourceDir + "/" + file);
- const { error } = appObject.validate(appItem);
-
- if (error) {
- const message = error?.details[0].message || "No message provided";
- logger.error("Skipping app source due to validation error", { message, file });
-
- continue;
- }
-
- apps.push({ ...appItem, id: name + "/" + file.replace(".nexterm.yml", "") });
- }
- }
-};
-
-module.exports.refreshAppSources = async () => {
- const appSources = await AppSource.findAll();
-
- apps = [];
-
- for (const appSource of appSources) {
- try {
- const path = await downloadAppSource(appSource.name, appSource.url);
-
- if (fs.existsSync(process.cwd() + "/data/sources/" + appSource.name)) {
- fs.rmSync(process.cwd() + "/data/sources/" + appSource.name, { recursive: true });
- }
-
- fs.mkdirSync(process.cwd() + "/data/sources/" + appSource.name);
-
- await extractNextermFiles(path, process.cwd() + "/data/sources/" + appSource.name);
-
- await fs.promises.unlink(path)
- .catch(err => logger.error("Error deleting downloaded file", { error: err.message }));
-
- await parseAppSource(appSource.name, process.cwd() + "/data/sources/" + appSource.name);
- } catch (err) {
- logger.error("Error refreshing app source", { appSource: appSource.name, error: err.message });
- }
- }
-
- logger.info("Refreshed app sources");
-
- refreshScripts();
-};
-
-module.exports.insertOfficialSource = async () => {
- const officialSource = await AppSource.findOne({ where: { name: "official" } });
-
- if (officialSource !== null) return;
-
- await AppSource.create({ name: "official", url: OFFICIAL_SOURCE });
-};
-
-module.exports.startAppUpdater = async () => {
- refreshTimer = setInterval(async () => {
- await this.refreshAppSources();
- }, 1000 * 60 * 60 * 24);
-};
-
-module.exports.stopAppUpdater = () => clearInterval(refreshTimer);
-
-module.exports.getApps = () => apps;
-
-module.exports.getApp = async (id) => {
- const app = apps.find(app => app.id === id);
- if (!app) return null;
-
- return app;
-};
-
-module.exports.getComposeFile = (id) => {
- const app = apps.find(app => app.id === id);
- if (!app) return null;
-
- const source = app.id.split("/")[0];
- const folder = app.id.split("/")[1];
-
- return fs.readFileSync(process.cwd() + `/data/sources/${source}/${folder}.nexterm.yml`, "utf8");
-};
-
-module.exports.getAppsByCategory = async (category) => {
- return apps.filter(app => app.category.toLowerCase() === category.toLowerCase());
-};
-
-module.exports.searchApp = async (search) => {
- return apps.filter(app => app.name.toLowerCase().includes(search.toLowerCase()));
-};
\ No newline at end of file
diff --git a/server/controllers/script.js b/server/controllers/script.js
index c36d29bd6..308a50a9e 100644
--- a/server/controllers/script.js
+++ b/server/controllers/script.js
@@ -1,207 +1,125 @@
-const logger = require("../utils/logger");
-const fs = require("fs");
-const path = require("path");
-
-let scripts = [];
-
-const parseScriptFile = (filePath) => {
- const content = fs.readFileSync(filePath, "utf8");
- const lines = content.split("\n");
-
- const metadata = { name: "Unknown Script", description: "No description provided" };
- let contentStartIndex = 0;
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- const trimmed = line.trim();
-
- if (i === 0 && trimmed.startsWith("#!")) {
- contentStartIndex = i + 1;
- continue;
- }
-
- if (trimmed.startsWith("#")) {
- const metaMatch = trimmed.match(/^#\s*@(\w+):\s*(.+)$/);
- if (metaMatch) {
- const [, key, value] = metaMatch;
- if (key in metadata) {
- metadata[key] = value.trim();
- }
- contentStartIndex = i + 1;
- continue;
- }
-
- if (Object.values(metadata).some(val => val !== "Unknown Script" && val !== "No description provided")) {
- break;
- }
- } else if (trimmed !== "") {
- break;
- }
-
- if (trimmed === "") {
- contentStartIndex = i + 1;
- }
- }
-
- const scriptContent = lines.slice(contentStartIndex).join("\n").trim();
+const Script = require("../models/Script");
+const { Op } = require("sequelize");
- return { ...metadata, content: scriptContent, type: "script" };
+module.exports.createScript = async (accountId, configuration) => {
+ return await Script.create({ ...configuration, accountId });
};
-const parseCustomScripts = (accountId) => {
- const customDir = path.join(process.cwd(), "data/sources/custom", accountId.toString());
- if (!fs.existsSync(customDir)) {
- return [];
+module.exports.deleteScript = async (accountId, scriptId, organizationId = null) => {
+ const whereClause = organizationId
+ ? { id: scriptId, organizationId }
+ : { id: scriptId, accountId, organizationId: null };
+
+ const script = await Script.findOne({ where: whereClause });
+
+ if (script === null) {
+ return { code: 404, message: "Script does not exist" };
}
- const files = fs.readdirSync(customDir);
- const customScripts = [];
-
- for (const file of files) {
- if (file.endsWith(".nexterm.sh")) {
- try {
- const scriptData = parseScriptFile(path.join(customDir, file));
- customScripts.push({
- ...scriptData,
- id: `custom/${accountId}/${file.replace(".nexterm.sh", "")}`,
- source: "custom",
- });
- } catch (err) {
- logger.error("Error parsing custom script", { file, error: err.message });
- }
- }
+ if (script.sourceId) {
+ return { code: 403, message: "Cannot delete source-synced scripts" };
}
- return customScripts;
+ await Script.destroy({ where: { id: scriptId } });
};
-const parseScriptsFromSources = () => {
- const sourceScripts = [];
- const sourcesDir = path.join(process.cwd(), "data/sources");
+module.exports.editScript = async (accountId, scriptId, configuration, organizationId = null) => {
+ const whereClause = organizationId
+ ? { id: scriptId, organizationId }
+ : { id: scriptId, accountId, organizationId: null };
- if (!fs.existsSync(sourcesDir)) return sourceScripts;
+ const script = await Script.findOne({ where: whereClause });
- const sources = fs.readdirSync(sourcesDir);
-
- for (const source of sources) {
- if (source === "custom") continue;
-
- const sourceDir = path.join(sourcesDir, source);
- if (!fs.statSync(sourceDir).isDirectory()) continue;
-
- const files = fs.readdirSync(sourceDir);
+ if (script === null) {
+ return { code: 404, message: "Script does not exist" };
+ }
- for (const file of files) {
- if (file.endsWith(".nexterm.sh")) {
- try {
- const scriptData = parseScriptFile(path.join(sourceDir, file));
- sourceScripts.push({ ...scriptData, id: `${source}/${file.replace(".nexterm.sh", "")}`, source });
- } catch (err) {
- logger.error("Error parsing script from source", { file, source, error: err.message });
- }
- }
- }
+ if (script.sourceId) {
+ return { code: 403, message: "Cannot edit source-synced scripts" };
}
- return sourceScripts;
+ const { organizationId: _, accountId: __, ...updateData } = configuration;
+ await Script.update(updateData, { where: { id: scriptId } });
};
-module.exports.refreshScripts = (accountId = null) => {
- const sourceScripts = parseScriptsFromSources();
-
- if (accountId) {
- const customScripts = parseCustomScripts(accountId);
- scripts = [...sourceScripts, ...customScripts];
- } else {
- scripts = sourceScripts;
+module.exports.getScript = async (accountId, scriptId, organizationId = null, organizationIds = []) => {
+ if (organizationId) {
+ const script = await Script.findOne({ where: { id: scriptId, organizationId } });
+ if (script) return script;
}
- logger.info("Refreshed scripts", { count: scripts.length });
-};
-
-module.exports.getScripts = (accountId = null) => {
- if (accountId) {
- const sourceScripts = scripts.filter(s => s.source !== "custom");
- const customScripts = parseCustomScripts(accountId);
- return [...sourceScripts, ...customScripts];
+ const personalScript = await Script.findOne({
+ where: {
+ id: scriptId,
+ accountId,
+ organizationId: null,
+ sourceId: null,
+ },
+ });
+ if (personalScript) return personalScript;
+
+ if (organizationIds.length > 0) {
+ const orgScript = await Script.findOne({
+ where: {
+ id: scriptId,
+ organizationId: { [Op.in]: organizationIds },
+ },
+ });
+ if (orgScript) return orgScript;
}
- return scripts.filter(s => s.source !== "custom");
-};
-module.exports.getScript = (id, accountId = null) => {
- if (id.startsWith("custom/") && accountId) return parseCustomScripts(accountId).find(script => script.id === id);
- return scripts.find(script => script.id === id);
-};
+ const sourceScript = await Script.findOne({
+ where: {
+ id: scriptId,
+ sourceId: { [Op.ne]: null },
+ },
+ });
+ if (sourceScript) return sourceScript;
-module.exports.searchScripts = (search, accountId = null) => {
- const allScripts = module.exports.getScripts(accountId);
- return allScripts.filter(script => script.name.toLowerCase().includes(search.toLowerCase())
- || script.description.toLowerCase().includes(search.toLowerCase()));
+ return { code: 404, message: "Script does not exist" };
};
-module.exports.createCustomScript = (accountId, scriptData) => {
- const customDir = path.join(process.cwd(), "data/sources/custom", accountId.toString());
-
- if (!fs.existsSync(customDir)) {
- fs.mkdirSync(customDir, { recursive: true });
+module.exports.listScripts = async (accountId, organizationId = null) => {
+ if (organizationId) {
+ return await Script.findAll({ where: { organizationId } });
}
+ return await Script.findAll({ where: { accountId, organizationId: null, sourceId: null } });
+};
- const fileName = `${scriptData.name.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase()}.nexterm.sh`;
- const filePath = path.join(customDir, fileName);
-
- if (fs.existsSync(filePath)) throw new Error("A script with this name already exists");
-
- const scriptContent = `#!/bin/bash
-# @name: ${scriptData.name}
-# @description: ${scriptData.description}
-
-${scriptData.content}
-`;
-
- fs.writeFileSync(filePath, scriptContent);
+module.exports.searchScripts = async (accountId, search, organizationId = null) => {
+ const whereClause = organizationId
+ ? { organizationId }
+ : { accountId, organizationId: null, sourceId: null };
+
+ return await Script.findAll({
+ where: {
+ ...whereClause,
+ [Op.or]: [
+ { name: { [Op.like]: `%${search}%` } },
+ { description: { [Op.like]: `%${search}%` } },
+ ],
+ },
+ });
+};
- return {
- id: `custom/${accountId}/${fileName.replace(".nexterm.sh", "")}`, ...scriptData,
- type: "script",
- source: "custom",
+module.exports.listAllAccessibleScripts = async (accountId, organizationIds = []) => {
+ const whereClause = {
+ [Op.or]: [
+ { accountId, organizationId: null, sourceId: null },
+ ...(organizationIds.length > 0 ? [{ organizationId: { [Op.in]: organizationIds } }] : []),
+ ],
};
+ return await Script.findAll({ where: whereClause });
};
-module.exports.updateCustomScript = (accountId, scriptId, scriptData) => {
- const scriptIdParts = scriptId.split("/");
- if (scriptIdParts[0] !== "custom" || scriptIdParts[1] !== accountId.toString()) {
- throw new Error("Unauthorized to edit this script");
- }
-
- const fileName = `${scriptIdParts[2]}.nexterm.sh`;
- const filePath = path.join(process.cwd(), "data/sources/custom", accountId.toString(), fileName);
-
- if (!fs.existsSync(filePath)) {
- throw new Error("Script not found");
- }
-
- const scriptContent = `#!/bin/bash
-# @name: ${scriptData.name}
-# @description: ${scriptData.description}
-
-${scriptData.content}
-`;
-
- fs.writeFileSync(filePath, scriptContent);
-
- return { id: scriptId, ...scriptData, type: "script", source: "custom" };
+module.exports.listSourceScripts = async (sourceId) => {
+ return await Script.findAll({ where: { sourceId } });
};
-module.exports.deleteCustomScript = (accountId, scriptId) => {
- const scriptIdParts = scriptId.split("/");
- if (scriptIdParts[0] !== "custom" || scriptIdParts[1] !== accountId.toString()) {
- throw new Error("Unauthorized to delete this script");
- }
-
- const fileName = `${scriptIdParts[2]}.nexterm.sh`;
- const filePath = path.join(process.cwd(), "data/sources/custom", accountId.toString(), fileName);
-
- if (!fs.existsSync(filePath)) throw new Error("Script not found");
-
- fs.unlinkSync(filePath);
+module.exports.listAllSourceScripts = async () => {
+ return await Script.findAll({
+ where: {
+ sourceId: { [Op.ne]: null },
+ },
+ });
};
diff --git a/server/controllers/serverSession.js b/server/controllers/serverSession.js
index d9a28d3fe..f8b274403 100644
--- a/server/controllers/serverSession.js
+++ b/server/controllers/serverSession.js
@@ -4,8 +4,9 @@ const Account = require("../models/Account");
const { validateEntryAccess } = require("./entry");
const { getOrganizationAuditSettingsInternal } = require("./audit");
const { resolveIdentity } = require("../utils/identityResolver");
+const Organization = require('../models/Organization');
-const createSession = async (accountId, entryId, identityId, connectionReason, type = null, directIdentity = null, tabId = null, browserId = null) => {
+const createSession = async (accountId, entryId, identityId, connectionReason, type = null, directIdentity = null, tabId = null, browserId = null, scriptId = null) => {
const entry = await Entry.findByPk(entryId);
if (!entry) {
return { code: 404, message: "Entry not found" };
@@ -38,6 +39,7 @@ const createSession = async (accountId, entryId, identityId, connectionReason, t
identityId: identity ? identity.id : null,
type: type || null,
directIdentity: directIdentity || null,
+ scriptId: scriptId || null,
};
const session = SessionManager.create(accountId, entryId, configuration, connectionReason, tabId, browserId);
@@ -66,10 +68,28 @@ const getSessions = async (accountId, tabId = null, browserId = null) => {
const sessions = SessionManager.getAll(accountId, filterTabId, filterBrowserId);
logger.info('Sessions found', { count: sessions.length });
- return sessions.map(session => {
+
+
+ return await Promise.all(sessions.map(async (session) => {
+ const entry = await Entry.findByPk(session.entryId, {
+ attributes: ['id', 'organizationId']
+ });
+
+ let organizationName = null;
+ if (entry?.organizationId) {
+ const org = await Organization.findByPk(entry.organizationId, {
+ attributes: ['name']
+ });
+ organizationName = org?.name || null;
+ }
+
const { connection, ...safeSession } = session;
- return safeSession;
- });
+ return {
+ ...safeSession,
+ organizationId: entry?.organizationId || null,
+ organizationName
+ };
+ }));
};
const hibernateSession = (sessionId) => {
diff --git a/server/controllers/snippet.js b/server/controllers/snippet.js
index c627a5f8c..d3795a5e0 100644
--- a/server/controllers/snippet.js
+++ b/server/controllers/snippet.js
@@ -1,39 +1,86 @@
const Snippet = require("../models/Snippet");
+const { Op } = require("sequelize");
module.exports.createSnippet = async (accountId, configuration) => {
return await Snippet.create({ ...configuration, accountId });
};
-module.exports.deleteSnippet = async (accountId, snippetId) => {
- const snippet = await Snippet.findOne({ where: { accountId: accountId, id: snippetId } });
+module.exports.deleteSnippet = async (accountId, snippetId, organizationId = null) => {
+ const whereClause = organizationId
+ ? { id: snippetId, organizationId }
+ : { id: snippetId, accountId, organizationId: null };
+
+ const snippet = await Snippet.findOne({ where: whereClause });
if (snippet === null) {
- return { code: 501, message: "Snippet does not exist" };
+ return { code: 404, message: "Snippet does not exist" };
+ }
+
+ if (snippet.sourceId) {
+ return { code: 403, message: "Cannot delete source-synced snippets" };
}
await Snippet.destroy({ where: { id: snippetId } });
};
-module.exports.editSnippet = async (accountId, snippetId, configuration) => {
- const snippet = await Snippet.findOne({ where: { accountId: accountId, id: snippetId } });
+module.exports.editSnippet = async (accountId, snippetId, configuration, organizationId = null) => {
+ const whereClause = organizationId
+ ? { id: snippetId, organizationId }
+ : { id: snippetId, accountId, organizationId: null };
+
+ const snippet = await Snippet.findOne({ where: whereClause });
if (snippet === null) {
- return { code: 501, message: "Snippet does not exist" };
+ return { code: 404, message: "Snippet does not exist" };
}
- await Snippet.update(configuration, { where: { id: snippetId } });
+ if (snippet.sourceId) {
+ return { code: 403, message: "Cannot edit source-synced snippets" };
+ }
+
+ const { organizationId: _, accountId: __, ...updateData } = configuration;
+ await Snippet.update(updateData, { where: { id: snippetId } });
};
-module.exports.getSnippet = async (accountId, snippetId) => {
- const snippet = await Snippet.findOne({ where: { accountId: accountId, id: snippetId } });
+module.exports.getSnippet = async (accountId, snippetId, organizationId = null) => {
+ const whereClause = organizationId
+ ? { id: snippetId, organizationId }
+ : { id: snippetId, accountId, organizationId: null };
+
+ const snippet = await Snippet.findOne({ where: whereClause });
if (snippet === null) {
- return { code: 501, message: "Snippet does not exist" };
+ return { code: 404, message: "Snippet does not exist" };
}
return snippet;
};
-module.exports.listSnippets = async (accountId) => {
- return await Snippet.findAll({ where: { accountId } });
+module.exports.listSnippets = async (accountId, organizationId = null) => {
+ if (organizationId) {
+ return await Snippet.findAll({ where: { organizationId } });
+ }
+ return await Snippet.findAll({ where: { accountId, organizationId: null, sourceId: null } });
+};
+
+module.exports.listAllAccessibleSnippets = async (accountId, organizationIds = []) => {
+ const whereClause = {
+ [Op.or]: [
+ { accountId, organizationId: null, sourceId: null },
+ ...(organizationIds.length > 0 ? [{ organizationId: { [Op.in]: organizationIds } }] : [])
+ ]
+ };
+ return await Snippet.findAll({ where: whereClause });
+};
+
+module.exports.listSourceSnippets = async (sourceId) => {
+ return await Snippet.findAll({ where: { sourceId } });
+};
+
+module.exports.listAllSourceSnippets = async () => {
+ return await Snippet.findAll({
+ where: {
+ sourceId: { [Op.ne]: null }
+ }
+ });
};
\ No newline at end of file
diff --git a/server/controllers/source.js b/server/controllers/source.js
new file mode 100644
index 000000000..fcca2f86d
--- /dev/null
+++ b/server/controllers/source.js
@@ -0,0 +1,439 @@
+const Source = require("../models/Source");
+const Snippet = require("../models/Snippet");
+const Script = require("../models/Script");
+const crypto = require("crypto");
+const logger = require("../utils/logger");
+
+module.exports.validateSourceUrl = async (url) => {
+ try {
+ const baseUrl = url.replace(/\/$/, "");
+ const indexUrl = `${baseUrl}/NTINDEX`;
+
+ const response = await fetch(indexUrl, {
+ method: "GET",
+ headers: {
+ "User-Agent": "Nexterm/1.0",
+ },
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!response.ok) {
+ return { valid: false, error: `Failed to fetch NTINDEX: HTTP ${response.status}` };
+ }
+
+ const indexContent = await response.text();
+ const parsedIndex = parseNTINDEX(indexContent);
+
+ if (!parsedIndex.snippets.length && !parsedIndex.scripts.length) {
+ return { valid: false, error: "NTINDEX is empty or invalid" };
+ }
+
+ return { valid: true, index: parsedIndex };
+ } catch (error) {
+ if (error.name === "TimeoutError") {
+ return { valid: false, error: "Request timed out" };
+ }
+ return { valid: false, error: error.message };
+ }
+};
+
+const parseNTINDEX = (content) => {
+ const lines = content.split("\n").filter(line => line.trim() && !line.startsWith("#"));
+ const snippets = [];
+ const scripts = [];
+
+ const scriptExtensions = [".sh", ".bash", ".zsh", ".fish", ".ps1"];
+ const snippetExtensions = [".txt", ".snippet", ".cmd"];
+
+ for (const line of lines) {
+ const parts = line.split("@").map(p => p.trim());
+ if (parts.length < 2) continue;
+
+ const [path, hash] = parts;
+ const ext = path.substring(path.lastIndexOf(".")).toLowerCase();
+ const entry = { hash, path };
+
+ if (scriptExtensions.includes(ext)) {
+ scripts.push(entry);
+ } else if (snippetExtensions.includes(ext)) {
+ snippets.push(entry);
+ }
+ }
+
+ return { snippets, scripts };
+};
+
+const calculateContentHash = (content) => crypto.createHash("md5").update(content).digest("hex");
+
+
+const reconstructSnippetFile = (snippet) => {
+ let content = "";
+ content += `# @name: ${snippet.name}\n`;
+ if (snippet.description) {
+ content += `# @description: ${snippet.description}\n`;
+ }
+ content += snippet.command;
+ return content;
+};
+
+module.exports.createSource = async (sourceData) => {
+ const { name, url } = sourceData;
+
+ const normalizedUrl = url.replace(/\/$/, "");
+
+ const existingSource = await Source.findOne({ where: { url: normalizedUrl } });
+ if (existingSource) {
+ return { code: 409, message: "A source with this URL already exists" };
+ }
+
+ const validation = await module.exports.validateSourceUrl(normalizedUrl);
+ if (!validation.valid) {
+ return { code: 400, message: `Invalid source URL: ${validation.error}` };
+ }
+
+ const source = await Source.create({ name, url: normalizedUrl, enabled: true });
+
+ await module.exports.syncSource(source.id);
+
+ return source;
+};
+
+module.exports.listSources = async () => {
+ return await Source.findAll({ order: [["name", "ASC"]] });
+};
+
+module.exports.getSource = async (sourceId) => {
+ const source = await Source.findByPk(sourceId);
+ if (!source) {
+ return { code: 404, message: "Source not found" };
+ }
+ return source;
+};
+
+module.exports.updateSource = async (sourceId, updates) => {
+ const source = await Source.findByPk(sourceId);
+ if (!source) {
+ return { code: 404, message: "Source not found" };
+ }
+
+ const { name, url, enabled } = updates;
+ const updateData = {};
+
+ if (name !== undefined) updateData.name = name;
+ if (enabled !== undefined) updateData.enabled = enabled;
+
+ if (url !== undefined && url !== source.url) {
+ const normalizedUrl = url.replace(/\/$/, "");
+
+ const existingSource = await Source.findOne({
+ where: { url: normalizedUrl, id: { [require("sequelize").Op.ne]: sourceId } },
+ });
+ if (existingSource) {
+ return { code: 409, message: "A source with this URL already exists" };
+ }
+
+ const validation = await module.exports.validateSourceUrl(normalizedUrl);
+ if (!validation.valid) {
+ return { code: 400, message: `Invalid source URL: ${validation.error}` };
+ }
+
+ updateData.url = normalizedUrl;
+ }
+
+ await Source.update(updateData, { where: { id: sourceId } });
+
+ if (updateData.url) {
+ await module.exports.syncSource(sourceId);
+ }
+
+ return await Source.findByPk(sourceId);
+};
+
+module.exports.deleteSource = async (sourceId) => {
+ const source = await Source.findByPk(sourceId);
+ if (!source) {
+ return { code: 404, message: "Source not found" };
+ }
+
+ if (source.isDefault) {
+ return { code: 403, message: "Cannot delete the default source" };
+ }
+
+ await Snippet.destroy({ where: { sourceId } });
+ await Script.destroy({ where: { sourceId } });
+
+ await Source.destroy({ where: { id: sourceId } });
+};
+
+module.exports.syncSource = async (sourceId) => {
+ const source = await Source.findByPk(sourceId);
+ if (!source) {
+ return { success: false, error: "Source not found" };
+ }
+
+ if (!source.enabled) {
+ return { success: false, error: "Source is disabled" };
+ }
+
+ try {
+ logger.info(`Syncing source: ${source.name} (${source.url})`);
+
+ const validation = await module.exports.validateSourceUrl(source.url);
+ if (!validation.valid) {
+ await Source.update({
+ lastSyncStatus: "error",
+ }, { where: { id: sourceId } });
+ return { success: false, error: validation.error };
+ }
+
+ const { snippets: indexSnippets, scripts: indexScripts } = validation.index;
+
+ const existingSnippets = await Snippet.findAll({ where: { sourceId } });
+ const existingScripts = await Script.findAll({ where: { sourceId } });
+
+ const existingSnippetMap = new Map(existingSnippets.map(s => [s.name, s]));
+ const existingScriptMap = new Map(existingScripts.map(s => [s.name, s]));
+
+ let snippetCount = 0;
+ let scriptCount = 0;
+
+ const processedSnippetNames = new Set();
+ const processedScriptNames = new Set();
+
+ for (const indexSnippet of indexSnippets) {
+ let existingByHash = null;
+ for (const [name, snippet] of existingSnippetMap) {
+ const reconstructed = reconstructSnippetFile(snippet);
+ const existingHash = calculateContentHash(reconstructed);
+ if (existingHash === indexSnippet.hash) {
+ existingByHash = snippet;
+ processedSnippetNames.add(name);
+ break;
+ }
+ }
+
+ if (existingByHash) {
+ snippetCount++;
+ continue;
+ }
+
+ const content = await fetchSourceFile(source.url, indexSnippet.path);
+ if (!content) {
+ logger.warn(`Failed to fetch snippet: ${indexSnippet.path}`);
+ continue;
+ }
+
+ const fallbackName = indexSnippet.path.split("/").pop().replace(/\.[^.]+$/, "");
+ const parsed = parseSnippetContent(content, fallbackName);
+ const existing = existingSnippetMap.get(parsed.name);
+
+ processedSnippetNames.add(parsed.name);
+
+ if (existing) {
+ await Snippet.update({
+ name: parsed.name,
+ command: parsed.command,
+ description: parsed.description,
+ }, { where: { id: existing.id } });
+ } else {
+ await Snippet.create({
+ name: parsed.name,
+ command: parsed.command,
+ description: parsed.description,
+ accountId: 0,
+ organizationId: null,
+ sourceId,
+ });
+ }
+ snippetCount++;
+ }
+
+ for (const indexScript of indexScripts) {
+ let existingByHash = null;
+ for (const [name, script] of existingScriptMap) {
+ const existingHash = calculateContentHash(script.content);
+ if (existingHash === indexScript.hash) {
+ existingByHash = script;
+ processedScriptNames.add(name);
+ break;
+ }
+ }
+
+ if (existingByHash) {
+ scriptCount++;
+ continue;
+ }
+
+ const content = await fetchSourceFile(source.url, indexScript.path);
+ if (!content) {
+ logger.warn(`Failed to fetch script: ${indexScript.path}`);
+ continue;
+ }
+
+ const fallbackName = indexScript.path.split("/").pop().replace(/\.[^.]+$/, "");
+ const parsed = parseScriptContent(content, fallbackName);
+ const existing = existingScriptMap.get(parsed.name);
+
+ processedScriptNames.add(parsed.name);
+
+ if (existing) {
+ await Script.update({
+ name: parsed.name,
+ content: parsed.content,
+ description: parsed.description,
+ }, { where: { id: existing.id } });
+ } else {
+ await Script.create({
+ name: parsed.name,
+ content: parsed.content,
+ description: parsed.description,
+ accountId: 0,
+ organizationId: null,
+ sourceId,
+ });
+ }
+ scriptCount++;
+ }
+
+ for (const [name, snippet] of existingSnippetMap) {
+ if (!processedSnippetNames.has(name)) {
+ await Snippet.destroy({ where: { id: snippet.id } });
+ }
+ }
+ for (const [name, script] of existingScriptMap) {
+ if (!processedScriptNames.has(name)) {
+ await Script.destroy({ where: { id: script.id } });
+ }
+ }
+
+ await Source.update({
+ lastSyncStatus: "success",
+ snippetCount,
+ scriptCount,
+ }, { where: { id: sourceId } });
+
+ logger.info(`Source sync completed: ${source.name} - ${snippetCount} snippets, ${scriptCount} scripts`);
+ return { success: true };
+
+ } catch (error) {
+ logger.error(`Source sync failed: ${source.name}`, { error: error.message });
+ await Source.update({
+ lastSyncStatus: "error",
+ }, { where: { id: sourceId } });
+ return { success: false, error: error.message };
+ }
+};
+
+module.exports.syncAllSources = async () => {
+ const sources = await Source.findAll({ where: { enabled: true } });
+
+ for (const source of sources) {
+ await module.exports.syncSource(source.id);
+ }
+};
+
+const fetchSourceFile = async (baseUrl, path) => {
+ try {
+ const url = `${baseUrl}/${path}`;
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ "User-Agent": "Nexterm/1.0",
+ },
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ return await response.text();
+ } catch (error) {
+ return null;
+ }
+};
+
+const parseSnippetContent = (content, defaultName) => {
+ const lines = content.split("\n");
+ let name = defaultName;
+ let description = "";
+ const commandLines = [];
+
+ for (const line of lines) {
+ const nameLine = line.match(/^#\s*@name:\s*(.+)$/i);
+ if (nameLine) {
+ name = nameLine[1].trim();
+ continue;
+ }
+
+ const descLine = line.match(/^#\s*@description:\s*(.+)$/i);
+ if (descLine) {
+ description = descLine[1].trim();
+ continue;
+ }
+
+ if (line.startsWith("#") && commandLines.length === 0) {
+ continue;
+ }
+
+ commandLines.push(line);
+ }
+
+ return {
+ name,
+ command: commandLines.join("\n").trim(),
+ description,
+ };
+};
+
+const parseScriptContent = (content, defaultName) => {
+ const lines = content.split("\n");
+ let name = defaultName;
+ let description = "";
+
+ for (const line of lines) {
+ const nameLine = line.match(/^#\s*@name:\s*(.+)$/i);
+ if (nameLine) {
+ name = nameLine[1].trim();
+ continue;
+ }
+
+ const descLine = line.match(/^#\s*@description:\s*(.+)$/i);
+ if (descLine) {
+ description = descLine[1].trim();
+ break;
+ }
+ }
+
+ return { name, content: content, description };
+};
+
+module.exports.ensureDefaultSource = async () => {
+ const DEFAULT_SOURCE_URL = "https://source.nexterm.dev";
+ const DEFAULT_SOURCE_NAME = "Official";
+
+ const existingDefault = await Source.findOne({ where: { isDefault: true } });
+ if (existingDefault) {
+ if (existingDefault.url !== DEFAULT_SOURCE_URL) {
+ await Source.update({ url: DEFAULT_SOURCE_URL }, { where: { id: existingDefault.id } });
+ }
+ return;
+ }
+
+ const existingByUrl = await Source.findOne({ where: { url: DEFAULT_SOURCE_URL } });
+ if (existingByUrl) {
+ await Source.update({ isDefault: true }, { where: { id: existingByUrl.id } });
+ return;
+ }
+
+ await Source.create({
+ name: DEFAULT_SOURCE_NAME,
+ url: DEFAULT_SOURCE_URL,
+ enabled: true,
+ isDefault: true,
+ });
+
+ logger.info("Created default official source");
+};
+
+module.exports.parseNTINDEX = parseNTINDEX;
diff --git a/server/hooks/script.js b/server/hooks/script.js
new file mode 100644
index 000000000..b6df0bdf5
--- /dev/null
+++ b/server/hooks/script.js
@@ -0,0 +1,230 @@
+const { updateAuditLogWithSessionDuration } = require("../controllers/audit");
+const SessionManager = require("../lib/SessionManager");
+const { transformScript, processNextermLine, checkSudoPrompt, stripAnsi } = require("../utils/scriptUtils");
+const { parseResizeMessage, setupSSHEventHandlers } = require("../utils/sshEventHandlers");
+const logger = require("../utils/logger");
+
+const NEXTERM_COMMANDS = [
+ "NEXTERM_INPUT", "NEXTERM_SELECT", "NEXTERM_STEP", "NEXTERM_TABLE", "NEXTERM_MSGBOX",
+ "NEXTERM_WARN", "NEXTERM_INFO", "NEXTERM_CONFIRM", "NEXTERM_PROGRESS",
+ "NEXTERM_SUCCESS", "NEXTERM_SUMMARY"
+];
+
+const setupScriptStreamHandlers = (ws, stream) => {
+ let currentStep = 1;
+ let pendingInput = null;
+ let outputBuffer = "";
+
+ const sendStep = (stepNum, message) => ws.send(`\x02${stepNum},${message}`);
+ const sendOutput = (data) => ws.send(`\x01${data}`);
+ const sendPrompt = (promptData) => ws.send(`\x05${JSON.stringify(promptData)}`);
+
+ const handleOutput = (data) => {
+ const output = data.toString();
+ const sudoPrompt = checkSudoPrompt(output);
+ if (sudoPrompt) {
+ pendingInput = sudoPrompt;
+ sendPrompt(sudoPrompt);
+ return "";
+ }
+ return output;
+ };
+
+ const processLines = (lines) => {
+ for (const line of lines) {
+ logger.debug("Processing script line", { line: line.substring(0, 200), length: line.length });
+ const nextermCommand = processNextermLine(line);
+
+ if (nextermCommand) {
+ logger.debug("Found NEXTERM command", { type: nextermCommand.type, command: nextermCommand });
+ switch (nextermCommand.type) {
+ case "input":
+ case "select":
+ pendingInput = nextermCommand;
+ sendPrompt(nextermCommand);
+ break;
+ case "step":
+ sendStep(currentStep++, nextermCommand.description);
+ break;
+ case "warning":
+ ws.send(`\x06${JSON.stringify({ type: "warning", message: nextermCommand.message })}`);
+ break;
+ case "info":
+ ws.send(`\x07${JSON.stringify({ type: "info", message: nextermCommand.message })}`);
+ break;
+ case "success":
+ ws.send(`\x08${JSON.stringify({ type: "success", message: nextermCommand.message })}`);
+ break;
+ case "confirm":
+ pendingInput = {
+ ...nextermCommand,
+ variable: "NEXTERM_CONFIRM_RESULT",
+ prompt: nextermCommand.message,
+ type: "confirm",
+ };
+ sendPrompt(pendingInput);
+ break;
+ case "progress":
+ ws.send(`\x09${JSON.stringify({
+ type: "progress",
+ percentage: nextermCommand.percentage,
+ message: nextermCommand.message,
+ })}`);
+ break;
+ case "summary":
+ pendingInput = {
+ ...nextermCommand,
+ variable: "NEXTERM_SUMMARY_RESULT",
+ prompt: "Summary displayed",
+ type: "summary",
+ };
+ ws.send(`\x0B${JSON.stringify({
+ type: "summary",
+ title: nextermCommand.title,
+ data: nextermCommand.data,
+ })}`);
+ break;
+ case "table":
+ pendingInput = {
+ ...nextermCommand,
+ variable: "NEXTERM_TABLE_RESULT",
+ prompt: "Table displayed",
+ type: "table",
+ };
+ ws.send(`\x0C${JSON.stringify({
+ type: "table",
+ title: nextermCommand.title,
+ data: nextermCommand.data,
+ })}`);
+ break;
+ case "msgbox":
+ pendingInput = {
+ ...nextermCommand,
+ variable: "NEXTERM_MSGBOX_RESULT",
+ prompt: "Message box displayed",
+ type: "msgbox",
+ };
+ ws.send(`\x0D${JSON.stringify({
+ type: "msgbox",
+ title: nextermCommand.title,
+ message: nextermCommand.message,
+ })}`);
+ break;
+ }
+ } else if (line.trim()) {
+ const cleanLine = stripAnsi(line);
+ if (!NEXTERM_COMMANDS.some(cmd => cleanLine.includes(cmd))) {
+ sendOutput(line + "\n");
+ }
+ }
+ }
+ };
+
+ const onData = (data) => {
+ if (ws.readyState !== ws.OPEN) return;
+
+ const processedOutput = handleOutput(data);
+ if (!processedOutput) return;
+
+ outputBuffer += processedOutput;
+ const lines = outputBuffer.split("\n");
+ outputBuffer = lines.pop() || "";
+
+ processLines(lines);
+ };
+
+ stream.on("data", onData);
+ stream.stderr.on("data", (data) => {
+ const processedOutput = handleOutput(data);
+ if (processedOutput) {
+ const cleanOutput = stripAnsi(processedOutput);
+ if (!NEXTERM_COMMANDS.some(cmd => cleanOutput.includes(cmd))) {
+ sendOutput(processedOutput);
+ }
+ }
+ });
+
+ const messageHandler = (message) => {
+ try {
+ const resize = parseResizeMessage(message);
+ if (resize) {
+ stream.setWindow(resize.height, resize.width);
+ return;
+ }
+
+ const data = JSON.parse(message);
+ if (data.type === "input_response" && pendingInput) {
+ const value = data.value || pendingInput.default || "";
+
+ if (pendingInput.isSudoPassword) {
+ stream.write(value + "\n");
+ sendOutput("Password entered\n");
+ } else {
+ sendOutput(`${pendingInput.prompt}: ${value}\n`);
+ stream.write(value + "\n");
+ }
+
+ pendingInput = null;
+ } else if (data.type === "input_cancelled") {
+ stream.write("\x03");
+ }
+ } catch (e) {
+ // Not a JSON message, ignore
+ }
+ };
+
+ ws.on("message", messageHandler);
+
+ stream.on("close", (code) => {
+ ws.off("message", messageHandler);
+
+ if (outputBuffer.trim()) {
+ processLines([outputBuffer]);
+ }
+
+ if (code === 0) {
+ sendOutput("\nScript execution completed successfully!\n");
+ sendStep(currentStep, "Script execution completed");
+ } else {
+ ws.send(`\x03Script execution failed with exit code ${code}`);
+ }
+ });
+
+ return onData;
+};
+
+module.exports = async (ws, context) => {
+ const { auditLogId, serverSession, script } = context;
+ let { ssh } = context;
+ const connectionStartTime = Date.now();
+
+ ws.send(`\x0A${script.name}`);
+
+ ssh.on("ready", () => {
+ const transformedScript = transformScript(script.content);
+
+ ssh.exec(transformedScript, { pty: { term: "xterm-256color" } }, (err, stream) => {
+ if (err) {
+ ws.close(4008, `Exec error: ${err.message}`);
+ return;
+ }
+
+ if (serverSession) {
+ SessionManager.setConnection(serverSession.sessionId, { ssh, stream, auditLogId });
+ }
+
+ ws.send(`\x01Starting script execution: ${script.name}\n`);
+
+ const onData = setupScriptStreamHandlers(ws, stream);
+
+ ws.on("close", async () => {
+ stream.removeListener("data", onData);
+ await updateAuditLogWithSessionDuration(auditLogId, connectionStartTime);
+ ssh.end();
+ if (serverSession) SessionManager.remove(serverSession.sessionId);
+ });
+ });
+ });
+
+ setupSSHEventHandlers(ssh, ws, { auditLogId, serverSession, connectionStartTime });
+};
diff --git a/server/hooks/ssh.js b/server/hooks/ssh.js
index fffe8fd47..df3d33d52 100644
--- a/server/hooks/ssh.js
+++ b/server/hooks/ssh.js
@@ -1,5 +1,6 @@
const { updateAuditLogWithSessionDuration } = require("../controllers/audit");
const SessionManager = require("../lib/SessionManager");
+const { parseResizeMessage, setupSSHEventHandlers } = require("../utils/sshEventHandlers");
const setupStreamHandlers = (ws, stream) => {
const onData = (data) => {
@@ -10,16 +11,9 @@ const setupStreamHandlers = (ws, stream) => {
stream.on("data", onData);
ws.on("message", (data) => {
- if (data.startsWith("\x01")) {
- const resizeData = data.substring(1);
- if (resizeData.includes(",")) {
- const [width, height] = resizeData.split(",").map(Number);
- if (!isNaN(width) && !isNaN(height)) {
- stream.setWindow(height, width);
- return;
- }
- }
- stream.write(data);
+ const resize = parseResizeMessage(data);
+ if (resize) {
+ stream.setWindow(resize.height, resize.width);
} else {
stream.write(data);
}
@@ -74,28 +68,5 @@ module.exports = async (ws, context) => {
});
});
- ssh.on("error", (error) => {
- const errorMsg = error.level === "client-timeout" ? "Client Timeout reached" : `SSH Error: ${error.message}`;
- ws.close(error.level === "client-timeout" ? 4007 : 4005, errorMsg);
- if (serverSession) SessionManager.remove(serverSession.sessionId);
- });
-
- ssh.on("end", async () => {
- await updateAuditLogWithSessionDuration(auditLogId, connectionStartTime);
- ws.close(4006, "Connection closed");
- if (serverSession) SessionManager.remove(serverSession.sessionId);
- });
-
- ssh.on("exit", async () => {
- await updateAuditLogWithSessionDuration(auditLogId, connectionStartTime);
- ws.close(4006, "Connection exited");
- if (serverSession) SessionManager.remove(serverSession.sessionId);
- });
-
- ssh.on("close", async () => {
- await updateAuditLogWithSessionDuration(auditLogId, connectionStartTime);
- if (ssh._jumpConnections) ssh._jumpConnections.forEach(conn => conn.ssh.end());
- ws.close(4007, "Connection closed");
- if (serverSession) SessionManager.remove(serverSession.sessionId);
- });
+ setupSSHEventHandlers(ssh, ws, { auditLogId, serverSession, connectionStartTime });
};
diff --git a/server/index.js b/server/index.js
index ef78bb8ce..c3f673121 100644
--- a/server/index.js
+++ b/server/index.js
@@ -9,13 +9,9 @@ const { startStatusChecker, stopStatusChecker } = require("./utils/statusChecker
const { ensureInternalProvider } = require("./controllers/oidc");
const monitoringService = require("./utils/monitoringService");
const { generateOpenAPISpec } = require("./openapi");
-const {
- refreshAppSources,
- startAppUpdater,
- insertOfficialSource,
-} = require("./controllers/appSource");
const { isAdmin } = require("./middlewares/permission");
const logger = require("./utils/logger");
+const { startSourceSyncService, stopSourceSyncService } = require("./utils/sourceSyncService");
require("./utils/folder");
process.on("uncaughtException", (err) => require("./utils/errorHandling")(err));
@@ -43,6 +39,7 @@ app.ws("/api/ws/sftp", require("./routes/sftpWS"));
app.use("/api/entries/sftp-download", require("./routes/sftpDownload"));
app.use("/api/users", authenticate, isAdmin, require("./routes/users"));
+app.use("/api/sources", authenticate, isAdmin, require("./routes/source"));
app.use("/api/ai", authenticate, require("./routes/ai"));
app.use("/api/sessions", authenticate, require("./routes/session"));
app.use("/api/connections", authenticate, require("./routes/serverSession"));
@@ -57,9 +54,6 @@ app.use("/api/organizations", authenticate, require("./routes/organization"));
app.use("/api/tags", authenticate, require("./routes/tag"));
app.use("/api/keymaps", authenticate, require("./routes/keymap"));
-app.ws("/api/apps/installer", require("./routes/appInstaller"));
-app.ws("/api/scripts/executor", require("./routes/scriptExecutor"));
-app.use("/api/apps", authenticate, require("./routes/apps"));
app.use("/api/scripts", authenticate, require("./routes/scripts"));
if (process.env.NODE_ENV === "production") {
@@ -95,14 +89,10 @@ db.authenticate()
startStatusChecker();
- startAppUpdater();
-
- await insertOfficialSource();
-
- await refreshAppSources();
-
monitoringService.start();
+ startSourceSyncService();
+
app.listen(APP_PORT, () =>
logger.system(`Server listening on port ${APP_PORT}`)
);
@@ -113,6 +103,7 @@ process.on("SIGINT", async () => {
monitoringService.stop();
stopStatusChecker();
+ stopSourceSyncService();
await db.close();
diff --git a/server/migrations/0005-make-first-user-admin.js b/server/migrations/0005-make-first-user-admin.js
index f256db13d..5ccf64c94 100644
--- a/server/migrations/0005-make-first-user-admin.js
+++ b/server/migrations/0005-make-first-user-admin.js
@@ -1,16 +1,18 @@
-const Account = require("../models/Account");
-const logger = require('../utils/logger');
-const { DataTypes } = require("sequelize");
+const logger = require("../utils/logger");
module.exports = {
async up(queryInterface) {
- const firstUser = await Account.findOne({ order: [["id", "ASC"]] });
- if (firstUser) {
- await Account.update(
- { role: "admin" },
- { where: { id: firstUser.id } },
+ const [results] = await queryInterface.sequelize.query(
+ "SELECT id FROM accounts ORDER BY id ASC LIMIT 1",
+ );
+
+ if (results && results.length > 0) {
+ const firstUserId = results[0].id;
+ await queryInterface.sequelize.query(
+ "UPDATE accounts SET role = ? WHERE id = ?",
+ { replacements: ["admin", firstUserId] },
);
- logger.info(`First user ${firstUser.id} made admin`);
+ logger.info(`First user ${firstUserId} made admin`);
} else {
logger.info("No users found, skipping admin assignment");
}
diff --git a/server/migrations/0015-remove-app-sources.js b/server/migrations/0015-remove-app-sources.js
new file mode 100644
index 000000000..4fd66e472
--- /dev/null
+++ b/server/migrations/0015-remove-app-sources.js
@@ -0,0 +1,9 @@
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.sequelize.query("PRAGMA foreign_keys = OFF");
+
+ await queryInterface.dropTable("app_sources");
+
+ await queryInterface.sequelize.query("PRAGMA foreign_keys = ON");
+ },
+};
diff --git a/server/migrations/0016-add-scripts.js b/server/migrations/0016-add-scripts.js
new file mode 100644
index 000000000..6cb3e1836
--- /dev/null
+++ b/server/migrations/0016-add-scripts.js
@@ -0,0 +1,48 @@
+const { DataTypes } = require("sequelize");
+const logger = require("../utils/logger");
+
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.sequelize.query("PRAGMA foreign_keys = OFF");
+
+ const tableNames = await queryInterface.showAllTables();
+
+ if (!tableNames.includes("scripts")) {
+ await queryInterface.createTable("scripts", {
+ id: {
+ type: DataTypes.INTEGER,
+ primaryKey: true,
+ autoIncrement: true,
+ },
+ name: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ },
+ accountId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ references: { model: "accounts", key: "id" },
+ onDelete: "CASCADE",
+ },
+ description: {
+ type: DataTypes.TEXT,
+ allowNull: true,
+ },
+ content: {
+ type: DataTypes.TEXT,
+ allowNull: false,
+ },
+ createdAt: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ },
+ updatedAt: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ },
+ });
+ }
+
+ await queryInterface.sequelize.query("PRAGMA foreign_keys = ON");
+ },
+};
diff --git a/server/migrations/0017-add-organization-to-snippets-and-scripts.js b/server/migrations/0017-add-organization-to-snippets-and-scripts.js
new file mode 100644
index 000000000..274e7a075
--- /dev/null
+++ b/server/migrations/0017-add-organization-to-snippets-and-scripts.js
@@ -0,0 +1,29 @@
+const { DataTypes } = require("sequelize");
+
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.sequelize.query("PRAGMA foreign_keys = OFF");
+
+ const snippetsColumns = await queryInterface.describeTable("snippets");
+ if (!snippetsColumns.organizationId) {
+ await queryInterface.addColumn("snippets", "organizationId", {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ references: { model: "organizations", key: "id" },
+ onDelete: "CASCADE",
+ });
+ }
+
+ const scriptsColumns = await queryInterface.describeTable("scripts");
+ if (!scriptsColumns.organizationId) {
+ await queryInterface.addColumn("scripts", "organizationId", {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ references: { model: "organizations", key: "id" },
+ onDelete: "CASCADE",
+ });
+ }
+
+ await queryInterface.sequelize.query("PRAGMA foreign_keys = ON");
+ },
+};
diff --git a/server/migrations/0018-add-sources.js b/server/migrations/0018-add-sources.js
new file mode 100644
index 000000000..fbb3304e7
--- /dev/null
+++ b/server/migrations/0018-add-sources.js
@@ -0,0 +1,73 @@
+const { DataTypes } = require("sequelize");
+
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.createTable("sources", {
+ id: {
+ type: DataTypes.INTEGER,
+ primaryKey: true,
+ autoIncrement: true,
+ },
+ name: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ },
+ url: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ },
+ enabled: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: true,
+ },
+ isDefault: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+ lastSyncStatus: {
+ type: DataTypes.STRING,
+ allowNull: true,
+ },
+ snippetCount: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ scriptCount: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ createdAt: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ },
+ updatedAt: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ },
+ });
+
+ const snippetsColumns = await queryInterface.describeTable("snippets");
+ if (!snippetsColumns.sourceId) {
+ await queryInterface.addColumn("snippets", "sourceId", {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ references: { model: "sources", key: "id" },
+ onDelete: "CASCADE",
+ });
+ }
+
+ const scriptsColumns = await queryInterface.describeTable("scripts");
+ if (!scriptsColumns.sourceId) {
+ await queryInterface.addColumn("scripts", "sourceId", {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ references: { model: "sources", key: "id" },
+ onDelete: "CASCADE",
+ });
+ }
+ },
+};
diff --git a/server/models/AppSource.js b/server/models/AppSource.js
deleted file mode 100644
index b9db87741..000000000
--- a/server/models/AppSource.js
+++ /dev/null
@@ -1,14 +0,0 @@
-const Sequelize = require("sequelize");
-const db = require("../utils/database");
-
-module.exports = db.define("app_sources", {
- name: {
- type: Sequelize.STRING,
- allowNull: false,
- primaryKey: true,
- },
- url: {
- type: Sequelize.STRING,
- allowNull: false,
- },
-}, { freezeTableName: true, createdAt: false, updatedAt: false });
\ No newline at end of file
diff --git a/server/models/Script.js b/server/models/Script.js
new file mode 100644
index 000000000..2f02d5b79
--- /dev/null
+++ b/server/models/Script.js
@@ -0,0 +1,35 @@
+const Sequelize = require("sequelize");
+const db = require("../utils/database");
+
+module.exports = db.define("scripts", {
+ name: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ accountId: {
+ type: Sequelize.INTEGER,
+ allowNull: false,
+ references: { model: "accounts", key: "id" },
+ onDelete: "CASCADE",
+ },
+ organizationId: {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ references: { model: "organizations", key: "id" },
+ onDelete: "CASCADE",
+ },
+ sourceId: {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ references: { model: "sources", key: "id" },
+ onDelete: "CASCADE",
+ },
+ description: {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ },
+ content: {
+ type: Sequelize.TEXT,
+ allowNull: false,
+ },
+}, { freezeTableName: true, createdAt: true, updatedAt: true });
diff --git a/server/models/Snippet.js b/server/models/Snippet.js
index 4dd65fe5c..e9fc4b679 100644
--- a/server/models/Snippet.js
+++ b/server/models/Snippet.js
@@ -10,6 +10,18 @@ module.exports = db.define("snippets", {
type: Sequelize.INTEGER,
allowNull: false,
},
+ organizationId: {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ references: { model: "organizations", key: "id" },
+ onDelete: "CASCADE",
+ },
+ sourceId: {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ references: { model: "sources", key: "id" },
+ onDelete: "CASCADE",
+ },
command: {
type: Sequelize.TEXT,
allowNull: false,
diff --git a/server/models/Source.js b/server/models/Source.js
new file mode 100644
index 000000000..668ac638c
--- /dev/null
+++ b/server/models/Source.js
@@ -0,0 +1,37 @@
+const Sequelize = require("sequelize");
+const db = require("../utils/database");
+
+module.exports = db.define("sources", {
+ name: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ url: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ },
+ enabled: {
+ type: Sequelize.BOOLEAN,
+ allowNull: false,
+ defaultValue: true,
+ },
+ isDefault: {
+ type: Sequelize.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+ lastSyncStatus: {
+ type: Sequelize.STRING,
+ allowNull: true,
+ },
+ snippetCount: {
+ type: Sequelize.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ scriptCount: {
+ type: Sequelize.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+}, { freezeTableName: true, createdAt: true, updatedAt: true });
diff --git a/server/routes/appInstaller.js b/server/routes/appInstaller.js
deleted file mode 100644
index 0b875f868..000000000
--- a/server/routes/appInstaller.js
+++ /dev/null
@@ -1,87 +0,0 @@
-const { authenticateWS } = require("../utils/wsAuth");
-const { getApp } = require("../controllers/appSource");
-const { startContainer } = require("../utils/apps/startContainer");
-const { runPostInstallCommand, runPreInstallCommand } = require("../utils/apps/runCommand");
-const { downloadBaseImage } = require("../utils/apps/pullImage");
-const { installDocker } = require("../utils/apps/installDocker");
-const { checkPermissions } = require("../utils/apps/checkPermissions");
-const { checkDistro } = require("../utils/apps/checkDistro");
-const { createAuditLog, AUDIT_ACTIONS, RESOURCE_TYPES } = require("../controllers/audit");
-
-const wait = () => new Promise(resolve => setTimeout(resolve, 500));
-
-const replaceCommandVariables = (command, appId) => {
- return command
- .replace(/{appPath}/g, `/opt/nexterm_apps/${appId.replace("/", "_")}`)
- .replace(/{appId}/g, appId.replace("/", "_"));
-}
-
-module.exports = async (ws, req) => {
- const authResult = await authenticateWS(ws, req, { requiredParams: ['sessionToken', 'serverId', 'appId'] });
-
- if (!authResult) return;
-
- const { identity, ssh, user, server } = authResult;
-
- const app = await getApp(req.query.appId);
- if (!app) {
- ws.close(4010, "The app does not exist");
- return;
- }
-
- await createAuditLog({
- accountId: user.id,
- organizationId: server.organizationId,
- action: AUDIT_ACTIONS.APP_INSTALL,
- resource: RESOURCE_TYPES.APP,
- resourceId: null,
- details: {
- appId: app.id,
- appName: app.name,
- },
- ipAddress: req.ip || req.connection?.remoteAddress || 'unknown',
- userAgent: req.headers['user-agent'] || 'unknown'
- });
-
- if (!ssh) {
- ws.close(4009, "SSH connection failed");
- return;
- }
-
- ssh.on("ready", async () => {
- try {
- await checkDistro(ssh, ws);
- await wait();
- const cmdPrefix = await checkPermissions(ssh, ws, identity);
-
- await wait();
- await installDocker(ssh, ws, cmdPrefix);
-
- if (app.preInstallCommand) {
- await wait();
- await runPreInstallCommand(ssh, ws, replaceCommandVariables(app.preInstallCommand, app.id), cmdPrefix);
- }
-
- await wait();
- await downloadBaseImage(ssh, ws, app.id, cmdPrefix);
-
- await wait();
- await startContainer(ssh, ws, app.id, undefined, undefined, true, cmdPrefix);
-
- if (app.postInstallCommand) {
- await wait();
- await runPostInstallCommand(ssh, ws, replaceCommandVariables(app.postInstallCommand, app.id), cmdPrefix);
- }
-
- ssh.end();
- } catch (err) {
- ws.send(`\x03${err.message}`);
- ssh.end();
- }
- });
-
- ssh.on("error", (error) => {
- ws.send(`\x03SSH connection error: ${error.message}`);
- ws.close(4005, "SSH connection failed");
- });
-};
\ No newline at end of file
diff --git a/server/routes/apps.js b/server/routes/apps.js
deleted file mode 100644
index b62025e5a..000000000
--- a/server/routes/apps.js
+++ /dev/null
@@ -1,134 +0,0 @@
-const { Router } = require("express");
-const {
- getAppsByCategory,
- getApps,
- searchApp,
- getAppSources,
- createAppSource,
- deleteAppSource,
- updateAppUrl,
- refreshAppSources,
-} = require("../controllers/appSource");
-const { validateSchema } = require("../utils/schema");
-const { createAppSourceValidation, updateAppUrlValidation } = require("../validations/appSource");
-const { isAdmin } = require("../middlewares/permission");
-
-const app = Router();
-
-/**
- * POST /apps/refresh
- * @summary Refresh App Sources
- * @description Refreshes all configured app sources to update the available applications catalog with the latest packages and versions.
- * @tags Apps
- * @produces application/json
- * @security BearerAuth
- * @return {object} 200 - App sources successfully refreshed
- */
-app.post("/refresh", async (req, res) => {
- await refreshAppSources();
- res.json({ message: "Apps got successfully refreshed" });
-});
-
-/**
- * GET /apps
- * @summary Get Applications
- * @description Retrieves applications from the catalog. Supports filtering by category or searching by name. Without parameters, returns all available apps.
- * @tags Apps
- * @produces application/json
- * @security BearerAuth
- * @param {string} category.query - Filter applications by category
- * @param {string} search.query - Search applications by name or description
- * @return {array} 200 - List of applications matching the criteria
- */
-app.get("/", async (req, res) => {
- if (req.query.category) {
- res.json(await getAppsByCategory(req.query.category));
- } else if (req.query.search) {
- res.json(await searchApp(req.query.search));
- } else {
- res.json(await getApps());
- }
-});
-
-/**
- * GET /apps/sources
- * @summary List App Sources
- * @description Retrieves all configured application sources including official and custom repositories. Admin access required.
- * @tags Apps
- * @produces application/json
- * @security BearerAuth
- * @return {array} 200 - List of app sources
- * @return {object} 403 - Admin access required
- */
-app.get("/sources", isAdmin, async (req, res) => {
- res.json(await getAppSources());
-});
-
-/**
- * PUT /apps/sources
- * @summary Create App Source
- * @description Creates a new application source repository for extending the available apps catalog. Admin access required.
- * @tags Apps
- * @produces application/json
- * @security BearerAuth
- * @param {CreateAppSource} request.body.required - App source configuration including name and URL
- * @return {object} 200 - App source successfully created
- * @return {object} 403 - Admin access required
- */
-app.put("/sources", isAdmin, async (req, res) => {
- if (validateSchema(res, createAppSourceValidation, req.body)) return;
-
- const appSource = await createAppSource(req.body);
- if (appSource?.code) return res.json(appSource);
-
- res.json({ message: "App source got successfully created" });
-});
-
-/**
- * DELETE /apps/sources/{appSource}
- * @summary Delete App Source
- * @description Permanently removes a custom application source. The official source cannot be deleted. Admin access required.
- * @tags Apps
- * @produces application/json
- * @security BearerAuth
- * @param {string} appSource.path.required - The identifier of the app source to delete
- * @return {object} 200 - App source successfully deleted
- * @return {object} 400 - Cannot delete the official app source
- * @return {object} 403 - Admin access required
- */
-app.delete("/sources/:appSource", isAdmin, async (req, res) => {
- if (req.params.appSource === "official")
- return res.status(400).json({ code: 103, message: "You can't delete the default app source" });
-
- const appSource = await deleteAppSource(req.params.appSource);
- if (appSource?.code) return res.json(appSource);
-
- res.json({ message: "App source got successfully deleted" });
-});
-
-/**
- * PATCH /apps/sources/{appSource}
- * @summary Update App Source
- * @description Updates the URL of a custom application source. The official source cannot be modified. Admin access required.
- * @tags Apps
- * @produces application/json
- * @security BearerAuth
- * @param {string} appSource.path.required - The identifier of the app source to update
- * @param {UpdateAppUrl} request.body.required - Updated URL for the app source
- * @return {object} 200 - App source successfully updated
- * @return {object} 400 - Cannot edit the official app source
- * @return {object} 403 - Admin access required
- */
-app.patch("/sources/:appSource", isAdmin, async (req, res) => {
- if (req.params.appSource === "official")
- return res.status(400).json({ code: 103, message: "You can't edit the default app source" });
-
- if (validateSchema(res, updateAppUrlValidation, req.body)) return;
-
- const appSource = await updateAppUrl(req.params.appSource, req.body.url);
- if (appSource?.code) return res.json(appSource);
-
- res.json({ message: "App source got successfully edited" });
-});
-
-module.exports = app;
diff --git a/server/routes/scriptExecutor.js b/server/routes/scriptExecutor.js
deleted file mode 100644
index 2d117edde..000000000
--- a/server/routes/scriptExecutor.js
+++ /dev/null
@@ -1,230 +0,0 @@
-const { authenticateWS } = require("../utils/wsAuth");
-const { getScript } = require("../controllers/script");
-const { transformScript, processNextermLine, checkSudoPrompt } = require("../utils/scriptUtils");
-const { createAuditLog, AUDIT_ACTIONS, RESOURCE_TYPES } = require("../controllers/audit");
-
-const executeScript = async (ssh, ws, scriptContent) => {
- let currentStep = 1;
- let pendingInput = null;
-
- const sendStep = (stepNum, message) => ws.send(`\x02${stepNum},${message}`);
- const sendOutput = (data) => ws.send(`\x01${data}`);
- const sendPrompt = (promptData) => ws.send(`\x05${JSON.stringify(promptData)}`);
-
- const transformedScript = transformScript(scriptContent);
-
- return new Promise((resolve, reject) => {
- let sshStream;
-
- const messageHandler = (message) => {
- try {
- const data = JSON.parse(message);
- if (data.type === "input_response" && pendingInput && sshStream) {
- const value = data.value || pendingInput.default || "";
-
- if (pendingInput.isSudoPassword) {
- sshStream.stdin.write(value + "\n");
- sendOutput("Password entered\n");
- } else {
- sendOutput(`${pendingInput.prompt}: ${value}\n`);
- sshStream.stdin.write(value + "\n");
- }
-
- pendingInput = null;
- } else if (data.type === "input_cancelled") {
- if (sshStream) sshStream.stdin.write("\x03");
- reject(new Error("Script execution cancelled by user"));
- }
- } catch (e) {
- }
- };
-
- const handleOutput = (data) => {
- const output = data.toString();
-
- const sudoPrompt = checkSudoPrompt(output);
- if (sudoPrompt) {
- pendingInput = sudoPrompt;
- sendPrompt(sudoPrompt);
- return "";
- }
-
- return output;
- };
-
- const processLines = (lines) => {
- for (const line of lines) {
- const nextermCommand = processNextermLine(line);
-
- if (nextermCommand) {
- switch (nextermCommand.type) {
- case "input":
- case "select":
- pendingInput = nextermCommand;
- sendPrompt(nextermCommand);
- break;
- case "step":
- sendStep(currentStep++, nextermCommand.description);
- break;
- case "warning":
- ws.send(`\x06${JSON.stringify({ type: "warning", message: nextermCommand.message })}`);
- break;
- case "info":
- ws.send(`\x07${JSON.stringify({ type: "info", message: nextermCommand.message })}`);
- break;
- case "success":
- ws.send(`\x08${JSON.stringify({ type: "success", message: nextermCommand.message })}`);
- break;
- case "confirm":
- pendingInput = {
- ...nextermCommand,
- variable: "NEXTERM_CONFIRM_RESULT",
- prompt: nextermCommand.message,
- type: "confirm",
- };
- sendPrompt(pendingInput);
- break;
- case "progress":
- ws.send(`\x09${JSON.stringify({
- type: "progress",
- percentage: nextermCommand.percentage,
- message: nextermCommand.message,
- })}`);
- break;
- case "summary":
- pendingInput = {
- ...nextermCommand,
- variable: "NEXTERM_SUMMARY_RESULT",
- prompt: "Summary displayed",
- type: "summary",
- };
- ws.send(`\x0B${JSON.stringify({
- type: "summary",
- title: nextermCommand.title,
- data: nextermCommand.data,
- })}`);
- break;
- case "table":
- pendingInput = {
- ...nextermCommand,
- variable: "NEXTERM_TABLE_RESULT",
- prompt: "Table displayed",
- type: "table",
- };
- ws.send(`\x0C${JSON.stringify({
- type: "table",
- title: nextermCommand.title,
- data: nextermCommand.data,
- })}`);
- break;
- case "msgbox":
- pendingInput = {
- ...nextermCommand,
- variable: "NEXTERM_MSGBOX_RESULT",
- prompt: "Message box displayed",
- type: "msgbox",
- };
- ws.send(`\x0D${JSON.stringify({
- type: "msgbox",
- title: nextermCommand.title,
- message: nextermCommand.message,
- })}`);
- break;
- }
- } else if (line.trim()) {
- sendOutput(line + "\n");
- }
- }
- };
-
- ws.on("message", messageHandler);
-
- ssh.exec(transformedScript, (err, stream) => {
- if (err) {
- ws.off("message", messageHandler);
- reject(new Error(`Failed to start script execution: ${err.message}`));
- return;
- }
-
- sshStream = stream;
- let outputBuffer = "";
-
- sshStream.on("data", (data) => {
- const processedOutput = handleOutput(data);
- if (!processedOutput) return;
-
- outputBuffer += processedOutput;
- const lines = outputBuffer.split("\n");
- outputBuffer = lines.pop() || "";
-
- processLines(lines);
- });
-
- sshStream.stderr.on("data", (data) => {
- const processedOutput = handleOutput(data);
- if (!processedOutput) return;
-
- if (!["NEXTERM_INPUT", "NEXTERM_SELECT", "NEXTERM_STEP", "NEXTERM_TABLE", "NEXTERM_MSGBOX"].some(cmd => processedOutput.includes(cmd))) {
- sendOutput(processedOutput);
- }
- });
-
- sshStream.on("close", (code) => {
- ws.off("message", messageHandler);
-
- if (code === 0) {
- sendOutput("\nScript execution completed successfully!\n");
- sendStep(currentStep, "Script execution completed");
- resolve();
- } else {
- reject(new Error(`Script execution failed with exit code ${code}`));
- }
- });
-
- sshStream.on("error", (error) => {
- ws.off("message", messageHandler);
- reject(new Error(`Script execution error: ${error.message}`));
- });
- });
- });
-};
-
-module.exports = async (ws, req) => {
- const authResult = await authenticateWS(ws, req, { requiredParams: ["sessionToken", "serverId", "scriptId"] });
-
- if (!authResult) return;
-
- const { user, server, ssh } = authResult;
-
- const script = getScript(req.query.scriptId, user.id);
- if (!script) {
- ws.close(4010, "The script does not exist");
- return;
- }
-
- ssh.on("ready", async () => {
- try {
- await createAuditLog({
- accountId: user.id,
- organizationId: server?.organizationId || null,
- action: AUDIT_ACTIONS.SCRIPT_EXECUTE,
- resource: RESOURCE_TYPES.SCRIPT,
- resourceId: req.query.scriptId,
- details: {
- scriptName: script.name,
- scriptSource: script.source,
- serverId: server?.id,
- },
- ipAddress: req.ip || req.socket?.remoteAddress,
- userAgent: req.headers?.["user-agent"],
- });
-
- ws.send(`\x01Starting script execution: ${script.name}\n`);
- await executeScript(ssh, ws, script.content);
- ssh.end();
- } catch (err) {
- ws.send(`\x03${err.message}`);
- ssh.end();
- }
- });
-};
diff --git a/server/routes/scripts.js b/server/routes/scripts.js
index 79dae1f5d..8f5bfb74c 100644
--- a/server/routes/scripts.js
+++ b/server/routes/scripts.js
@@ -1,53 +1,87 @@
const { Router } = require("express");
const {
- getScripts,
+ listScripts,
getScript,
searchScripts,
- createCustomScript,
- updateCustomScript,
- deleteCustomScript,
- refreshScripts,
+ createScript,
+ editScript,
+ deleteScript,
+ listAllAccessibleScripts,
+ listAllSourceScripts,
} = require("../controllers/script");
const { validateSchema } = require("../utils/schema");
-const { scriptValidation } = require("../validations/script");
+const { scriptCreationValidation, scriptEditValidation } = require("../validations/script");
+const OrganizationMember = require("../models/OrganizationMember");
const app = Router();
/**
- * POST /scripts/refresh
- * @summary Refresh Scripts
- * @description Refreshes the scripts catalog by reloading all available scripts from configured sources and updating the user's script library.
+ * GET /scripts
+ * @summary Get Scripts
+ * @description Retrieves available scripts for the authenticated user. Supports searching by name or description when search parameter is provided.
* @tags Scripts
* @produces application/json
* @security BearerAuth
- * @return {object} 200 - Scripts successfully refreshed
+ * @param {string} search.query - Search term to filter scripts by name or description
+ * @param {string} organizationId.query - Optional: Filter scripts by organization ID
+ * @return {array} 200 - List of scripts available to the user
*/
-app.post("/refresh", async (req, res) => {
+app.get("/", async (req, res) => {
try {
- refreshScripts(req.user.id);
- res.json({ message: "Scripts got successfully refreshed" });
+ const organizationId = req.query.organizationId ? parseInt(req.query.organizationId) : null;
+
+ if (organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
+ if (req.query.search) {
+ res.json(await searchScripts(req.user.id, req.query.search, organizationId));
+ } else {
+ res.json(await listScripts(req.user.id, organizationId));
+ }
} catch (error) {
res.status(500).json({ code: 500, message: error.message });
}
});
/**
- * GET /scripts
- * @summary Get Scripts
- * @description Retrieves available scripts for the authenticated user. Supports searching by name or description when search parameter is provided.
+ * GET /scripts/all
+ * @summary List All Accessible Scripts
+ * @description Retrieves all scripts accessible to the user (personal + organization scripts)
* @tags Scripts
* @produces application/json
* @security BearerAuth
- * @param {string} search.query - Search term to filter scripts by name or description
- * @return {array} 200 - List of scripts available to the user
+ * @return {array} 200 - List of all accessible scripts
*/
-app.get("/", async (req, res) => {
+app.get("/all", async (req, res) => {
try {
- if (req.query.search) {
- res.json(searchScripts(req.query.search, req.user.id));
- } else {
- res.json(getScripts(req.user.id));
- }
+ const memberships = await OrganizationMember.findAll({ where: { accountId: req.user.id } });
+ const organizationIds = memberships.map(m => m.organizationId);
+
+ res.json(await listAllAccessibleScripts(req.user.id, organizationIds));
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * GET /scripts/sources
+ * @summary List All Source Scripts
+ * @description Retrieves all scripts from external sources
+ * @tags Scripts
+ * @produces application/json
+ * @security BearerAuth
+ * @return {array} 200 - List of all source scripts
+ */
+app.get("/sources", async (req, res) => {
+ try {
+ res.json(await listAllSourceScripts());
} catch (error) {
res.status(500).json({ code: 500, message: error.message });
}
@@ -60,17 +94,32 @@ app.get("/", async (req, res) => {
* @tags Scripts
* @produces application/json
* @security BearerAuth
- * @param {string} scriptId.path.required - The unique identifier of the script (URL encoded)
+ * @param {string} scriptId.path.required - The unique identifier of the script
+ * @param {string} organizationId.query - Optional: Organization ID if accessing organization script
* @return {object} 200 - Script details
* @return {object} 404 - Script not found
*/
app.get("/:scriptId", async (req, res) => {
try {
- const decodedScriptId = decodeURIComponent(req.params.scriptId);
- const script = getScript(decodedScriptId, req.user.id);
+ const organizationId = req.query.organizationId ? parseInt(req.query.organizationId) : null;
+
+ if (organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
+ const script = await getScript(req.user.id, req.params.scriptId, organizationId);
if (!script) {
return res.status(404).json({ code: 404, message: "Script not found" });
}
+ if (script.code) {
+ return res.status(script.code).json(script);
+ }
res.json(script);
} catch (error) {
res.status(500).json({ code: 500, message: error.message });
@@ -89,15 +138,22 @@ app.get("/:scriptId", async (req, res) => {
* @return {object} 409 - Script with this name already exists
*/
app.post("/", async (req, res) => {
- if (validateSchema(res, scriptValidation, req.body)) return;
+ if (validateSchema(res, scriptCreationValidation, req.body)) return;
try {
- const script = createCustomScript(req.user.id, req.body);
- res.status(201).json(script);
- } catch (error) {
- if (error.message === "A script with this name already exists") {
- return res.status(409).json({ code: 409, message: error.message });
+ if (req.body.organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId: req.body.organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
}
+
+ const script = await createScript(req.user.id, req.body);
+ res.status(201).json({ message: "Script created successfully", id: script.id });
+ } catch (error) {
res.status(500).json({ code: 500, message: error.message });
}
});
@@ -109,22 +165,34 @@ app.post("/", async (req, res) => {
* @tags Scripts
* @produces application/json
* @security BearerAuth
- * @param {string} scriptId.path.required - The unique identifier of the script to update (URL encoded)
+ * @param {string} scriptId.path.required - The unique identifier of the script to update
+ * @param {string} organizationId.query - Optional: Organization ID if updating organization script
* @param {Script} request.body.required - Updated script configuration
* @return {object} 200 - Script successfully updated
* @return {object} 404 - Script not found or unauthorized
*/
app.put("/:scriptId", async (req, res) => {
- if (validateSchema(res, scriptValidation, req.body)) return;
+ if (validateSchema(res, scriptEditValidation, req.body)) return;
try {
- const decodedScriptId = decodeURIComponent(req.params.scriptId);
- const script = updateCustomScript(req.user.id, decodedScriptId, req.body);
- res.json(script);
- } catch (error) {
- if (error.message === "Unauthorized to edit this script" || error.message === "Script not found") {
- return res.status(404).json({ code: 404, message: error.message });
+ const organizationId = req.query.organizationId ? parseInt(req.query.organizationId) : null;
+
+ if (organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
+ const result = await editScript(req.user.id, req.params.scriptId, req.body, organizationId);
+ if (result?.code) {
+ return res.status(result.code).json(result);
}
+ res.json({ message: "Script updated successfully" });
+ } catch (error) {
res.status(500).json({ code: 500, message: error.message });
}
});
@@ -136,19 +204,31 @@ app.put("/:scriptId", async (req, res) => {
* @tags Scripts
* @produces application/json
* @security BearerAuth
- * @param {string} scriptId.path.required - The unique identifier of the script to delete (URL encoded)
+ * @param {string} scriptId.path.required - The unique identifier of the script to delete
+ * @param {string} organizationId.query - Optional: Organization ID if deleting organization script
* @return {object} 200 - Script successfully deleted
* @return {object} 404 - Script not found or unauthorized
*/
app.delete("/:scriptId", async (req, res) => {
try {
- const decodedScriptId = decodeURIComponent(req.params.scriptId);
- deleteCustomScript(req.user.id, decodedScriptId);
+ const organizationId = req.query.organizationId ? parseInt(req.query.organizationId) : null;
+
+ if (organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
+ const result = await deleteScript(req.user.id, req.params.scriptId, organizationId);
+ if (result?.code) {
+ return res.status(result.code).json(result);
+ }
res.json({ message: "Script deleted successfully" });
} catch (error) {
- if (error.message === "Unauthorized to delete this script" || error.message === "Script not found") {
- return res.status(404).json({ code: 404, message: error.message });
- }
res.status(500).json({ code: 500, message: error.message });
}
});
diff --git a/server/routes/serverSession.js b/server/routes/serverSession.js
index a7d89d766..8fbbf290b 100644
--- a/server/routes/serverSession.js
+++ b/server/routes/serverSession.js
@@ -19,8 +19,8 @@ app.post("/", async (req, res) => {
if (validateSchema(res, createSessionValidation, req.body)) return;
try {
- const { entryId, identityId, connectionReason, type, directIdentity, tabId, browserId } = req.body;
- const result = await createSession(req.user.id, entryId, identityId, connectionReason, type, directIdentity, tabId, browserId);
+ const { entryId, identityId, connectionReason, type, directIdentity, tabId, browserId, scriptId } = req.body;
+ const result = await createSession(req.user.id, entryId, identityId, connectionReason, type, directIdentity, tabId, browserId, scriptId);
if (result?.code) {
return res.status(result.code).json({ error: result.message });
diff --git a/server/routes/sftpDownload.js b/server/routes/sftpDownload.js
index dc904d3fd..11397002c 100644
--- a/server/routes/sftpDownload.js
+++ b/server/routes/sftpDownload.js
@@ -102,7 +102,8 @@ app.get("/", async (req, res) => {
return;
}
- res.header("Content-Disposition", `attachment; filename="${path.split("/").pop()}"`);
+ const disposition = req.query.preview === 'true' ? 'inline' : 'attachment';
+ res.header("Content-Disposition", `${disposition}; filename="${path.split("/").pop()}"`);
res.header("Content-Length", stats.size);
const readStream = sftp.createReadStream(path);
diff --git a/server/routes/sftpWS.js b/server/routes/sftpWS.js
index dcd963c58..6c42192fa 100644
--- a/server/routes/sftpWS.js
+++ b/server/routes/sftpWS.js
@@ -80,6 +80,7 @@ module.exports = async (ws, req) => {
sftp.on("error", () => {});
let uploadStream = null;
+ let uploadFilePath = null;
ws.on("message", (msg) => {
const operation = msg[0];
@@ -106,12 +107,17 @@ module.exports = async (ws, req) => {
]));
return;
}
- const files = list.map(file => ({
- name: file.filename,
- type: file.longname.startsWith("d") ? "folder" : "file",
- last_modified: file.attrs.mtime,
- size: file.attrs.size,
- }));
+ const files = list.map(file => {
+ const isSymlink = file.longname.startsWith("l");
+ const isDirectory = file.longname.startsWith("d");
+ return {
+ name: file.filename,
+ type: isDirectory ? "folder" : "file",
+ isSymlink,
+ last_modified: file.attrs.mtime,
+ size: file.attrs.size,
+ };
+ });
ws.send(Buffer.concat([
Buffer.from([OPERATIONS.LIST_FILES]),
Buffer.from(JSON.stringify({ files })),
@@ -125,9 +131,11 @@ module.exports = async (ws, req) => {
}
try {
+ uploadFilePath = payload.path;
uploadStream = sftp.createWriteStream(payload.path);
uploadStream.on("error", () => {
uploadStream = null;
+ uploadFilePath = null;
ws.send(Buffer.concat([
Buffer.from([OPERATIONS.ERROR]),
Buffer.from(JSON.stringify({ message: "Permission denied - unable to upload file to this location" })),
@@ -137,6 +145,7 @@ module.exports = async (ws, req) => {
ws.send(Buffer.from([OPERATIONS.UPLOAD_FILE_START]));
} catch (err) {
uploadStream = null;
+ uploadFilePath = null;
ws.send(Buffer.concat([
Buffer.from([OPERATIONS.ERROR]),
Buffer.from(JSON.stringify({ message: "Failed to start file upload" })),
@@ -161,22 +170,27 @@ module.exports = async (ws, req) => {
case OPERATIONS.UPLOAD_FILE_END:
try {
if (uploadStream && !uploadStream.destroyed) {
+ const filePath = uploadFilePath;
uploadStream.end(() => {
uploadStream = null;
+ uploadFilePath = null;
ws.send(Buffer.from([OPERATIONS.UPLOAD_FILE_END]));
- createAuditLog({
- accountId: user.id,
- organizationId: entry.organizationId,
- action: AUDIT_ACTIONS.FILE_UPLOAD,
- resource: RESOURCE_TYPES.FILE,
- details: { filePath: payload.path },
- ipAddress,
- userAgent,
- });
+ if (filePath) {
+ createAuditLog({
+ accountId: user.id,
+ organizationId: entry.organizationId,
+ action: AUDIT_ACTIONS.FILE_UPLOAD,
+ resource: RESOURCE_TYPES.FILE,
+ details: { filePath },
+ ipAddress,
+ userAgent,
+ });
+ }
});
} else {
uploadStream = null;
+ uploadFilePath = null;
ws.send(Buffer.concat([
Buffer.from([OPERATIONS.ERROR]),
Buffer.from(JSON.stringify({ message: "Upload stream is not available" })),
@@ -184,6 +198,7 @@ module.exports = async (ws, req) => {
}
} catch (err) {
uploadStream = null;
+ uploadFilePath = null;
ws.send(Buffer.concat([
Buffer.from([OPERATIONS.ERROR]),
Buffer.from(JSON.stringify({ message: "Failed to complete file upload" })),
@@ -301,6 +316,34 @@ module.exports = async (ws, req) => {
});
break;
+ case OPERATIONS.RESOLVE_SYMLINK:
+ sftp.realpath(payload.path, (err, realPath) => {
+ if (err) {
+ ws.send(Buffer.concat([
+ Buffer.from([OPERATIONS.ERROR]),
+ Buffer.from(JSON.stringify({ message: "Failed to resolve symbolic link" })),
+ ]));
+ return;
+ }
+ sftp.stat(realPath, (err, stats) => {
+ if (err) {
+ ws.send(Buffer.concat([
+ Buffer.from([OPERATIONS.ERROR]),
+ Buffer.from(JSON.stringify({ message: "Failed to access symbolic link target" })),
+ ]));
+ return;
+ }
+ ws.send(Buffer.concat([
+ Buffer.from([OPERATIONS.RESOLVE_SYMLINK]),
+ Buffer.from(JSON.stringify({
+ path: realPath,
+ isDirectory: stats.isDirectory()
+ })),
+ ]));
+ });
+ });
+ break;
+
default:
logger.warn(`Unknown SFTP operation`, { operation });
}
diff --git a/server/routes/snippet.js b/server/routes/snippet.js
index b844eab36..7fe2c20d7 100644
--- a/server/routes/snippet.js
+++ b/server/routes/snippet.js
@@ -1,21 +1,39 @@
const { Router } = require("express");
const { validateSchema } = require("../utils/schema");
-const { createSnippet, deleteSnippet, editSnippet, getSnippet, listSnippets } = require("../controllers/snippet");
+const { createSnippet, deleteSnippet, editSnippet, getSnippet, listAllAccessibleSnippets, listAllSourceSnippets } = require("../controllers/snippet");
const { snippetCreationValidation, snippetEditValidation } = require("../validations/snippet");
+const OrganizationMember = require("../models/OrganizationMember");
const app = Router();
+
/**
- * GET /snippet/list
- * @summary List User Snippets
- * @description Retrieves a list of all code snippets created by the authenticated user for reusable commands and scripts.
+ * GET /snippet/all
+ * @summary List All Accessible Snippets
+ * @description Retrieves all snippets accessible to the user (personal + organization snippets)
* @tags Snippet
* @produces application/json
* @security BearerAuth
- * @return {array} 200 - List of user snippets
+ * @return {array} 200 - List of all accessible snippets
*/
-app.get("/list", async (req, res) => {
- res.json(await listSnippets(req.user.id));
+app.get("/all", async (req, res) => {
+ const memberships = await OrganizationMember.findAll({ where: { accountId: req.user.id } });
+ const organizationIds = memberships.map(m => m.organizationId);
+
+ res.json(await listAllAccessibleSnippets(req.user.id, organizationIds));
+});
+
+/**
+ * GET /snippet/sources
+ * @summary List All Source Snippets
+ * @description Retrieves all snippets from external sources
+ * @tags Snippet
+ * @produces application/json
+ * @security BearerAuth
+ * @return {array} 200 - List of all source snippets
+ */
+app.get("/sources", async (req, res) => {
+ res.json(await listAllSourceSnippets());
});
/**
@@ -26,12 +44,25 @@ app.get("/list", async (req, res) => {
* @produces application/json
* @security BearerAuth
* @param {string} snippetId.path.required - The unique identifier of the snippet
+ * @param {string} organizationId.query - Optional: Organization ID if accessing organization snippet
* @return {object} 200 - Snippet details
* @return {object} 404 - Snippet not found
*/
app.get("/:snippetId", async (req, res) => {
- const snippet = await getSnippet(req.user.id, req.params.snippetId);
- if (snippet?.code) return res.json(snippet);
+ const organizationId = req.query.organizationId ? parseInt(req.query.organizationId) : null;
+
+ if (organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
+ const snippet = await getSnippet(req.user.id, req.params.snippetId, organizationId);
+ if (snippet?.code) return res.status(snippet.code).json(snippet);
res.json(snippet);
});
@@ -50,8 +81,18 @@ app.get("/:snippetId", async (req, res) => {
app.put("/", async (req, res) => {
if (validateSchema(res, snippetCreationValidation, req.body)) return;
+ if (req.body.organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId: req.body.organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
const snippet = await createSnippet(req.user.id, req.body);
- if (snippet?.code) return res.json(snippet);
+ if (snippet?.code) return res.status(snippet.code).json(snippet);
res.json({ message: "Snippet created successfully", id: snippet.id });
});
@@ -64,6 +105,7 @@ app.put("/", async (req, res) => {
* @produces application/json
* @security BearerAuth
* @param {string} snippetId.path.required - The unique identifier of the snippet to update
+ * @param {string} organizationId.query - Optional: Organization ID if updating organization snippet
* @param {SnippetEdit} request.body.required - Updated snippet configuration fields
* @return {object} 200 - Snippet successfully updated
* @return {object} 404 - Snippet not found
@@ -71,8 +113,20 @@ app.put("/", async (req, res) => {
app.patch("/:snippetId", async (req, res) => {
if (validateSchema(res, snippetEditValidation, req.body)) return;
- const snippet = await editSnippet(req.user.id, req.params.snippetId, req.body);
- if (snippet?.code) return res.json(snippet);
+ const organizationId = req.query.organizationId ? parseInt(req.query.organizationId) : null;
+
+ if (organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
+ const snippet = await editSnippet(req.user.id, req.params.snippetId, req.body, organizationId);
+ if (snippet?.code) return res.status(snippet.code).json(snippet);
res.json({ message: "Snippet updated successfully" });
});
@@ -85,12 +139,25 @@ app.patch("/:snippetId", async (req, res) => {
* @produces application/json
* @security BearerAuth
* @param {string} snippetId.path.required - The unique identifier of the snippet to delete
+ * @param {string} organizationId.query - Optional: Organization ID if deleting organization snippet
* @return {object} 200 - Snippet successfully deleted
* @return {object} 404 - Snippet not found
*/
app.delete("/:snippetId", async (req, res) => {
- const snippet = await deleteSnippet(req.user.id, req.params.snippetId);
- if (snippet?.code) return res.json(snippet);
+ const organizationId = req.query.organizationId ? parseInt(req.query.organizationId) : null;
+
+ if (organizationId) {
+ const membership = await OrganizationMember.findOne({
+ where: { accountId: req.user.id, organizationId }
+ });
+
+ if (!membership) {
+ return res.status(403).json({ code: 403, message: "Access denied to this organization" });
+ }
+ }
+
+ const snippet = await deleteSnippet(req.user.id, req.params.snippetId, organizationId);
+ if (snippet?.code) return res.status(snippet.code).json(snippet);
res.json({ message: "Snippet deleted successfully" });
});
diff --git a/server/routes/source.js b/server/routes/source.js
new file mode 100644
index 000000000..8f15288cc
--- /dev/null
+++ b/server/routes/source.js
@@ -0,0 +1,201 @@
+const { Router } = require("express");
+const { validateSchema } = require("../utils/schema");
+const {
+ createSource,
+ listSources,
+ getSource,
+ updateSource,
+ deleteSource,
+ syncSource,
+ syncAllSources,
+ validateSourceUrl,
+} = require("../controllers/source");
+const {
+ sourceCreationValidation,
+ sourceUpdateValidation,
+ validateUrlValidation,
+} = require("../validations/source");
+
+const app = Router();
+
+/**
+ * GET /sources
+ * @summary List All Sources
+ * @description Retrieves all configured snippet/script sources
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @return {array} 200 - List of all sources
+ */
+app.get("/", async (req, res) => {
+ try {
+ const sources = await listSources();
+ res.json(sources);
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * GET /sources/:sourceId
+ * @summary Get Source Details
+ * @description Retrieves detailed information about a specific source
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @param {number} sourceId.path.required - The source ID
+ * @return {object} 200 - Source details
+ * @return {object} 404 - Source not found
+ */
+app.get("/:sourceId", async (req, res) => {
+ try {
+ const source = await getSource(parseInt(req.params.sourceId));
+ if (source?.code) return res.status(source.code).json(source);
+ res.json(source);
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * POST /sources/validate
+ * @summary Validate Source URL
+ * @description Validates a source URL by checking if NTINDEX is accessible and valid
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @param {object} request.body.required - URL to validate
+ * @return {object} 200 - Validation result
+ */
+app.post("/validate", async (req, res) => {
+ if (validateSchema(res, validateUrlValidation, req.body)) return;
+
+ try {
+ const result = await validateSourceUrl(req.body.url);
+ if (result.valid) {
+ res.json({
+ valid: true,
+ snippetCount: result.index.snippets.length,
+ scriptCount: result.index.scripts.length,
+ });
+ } else {
+ res.json({ valid: false, error: result.error });
+ }
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * POST /sources
+ * @summary Create New Source
+ * @description Creates a new snippet/script source and performs initial sync
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @param {object} request.body.required - Source configuration
+ * @return {object} 201 - Source created successfully
+ * @return {object} 400 - Invalid URL
+ * @return {object} 409 - Source with URL already exists
+ */
+app.post("/", async (req, res) => {
+ if (validateSchema(res, sourceCreationValidation, req.body)) return;
+
+ try {
+ const source = await createSource(req.body);
+ if (source?.code) return res.status(source.code).json(source);
+ res.status(201).json({ message: "Source created successfully", id: source.id });
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * PATCH /sources/:sourceId
+ * @summary Update Source
+ * @description Updates an existing source's name, URL, or enabled status
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @param {number} sourceId.path.required - The source ID
+ * @param {object} request.body.required - Updated source fields
+ * @return {object} 200 - Source updated successfully
+ * @return {object} 404 - Source not found
+ */
+app.patch("/:sourceId", async (req, res) => {
+ if (validateSchema(res, sourceUpdateValidation, req.body)) return;
+
+ try {
+ const source = await updateSource(parseInt(req.params.sourceId), req.body);
+ if (source?.code) return res.status(source.code).json(source);
+ res.json({ message: "Source updated successfully", source });
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * DELETE /sources/:sourceId
+ * @summary Delete Source
+ * @description Deletes a source and all its synced snippets/scripts
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @param {number} sourceId.path.required - The source ID
+ * @return {object} 200 - Source deleted successfully
+ * @return {object} 404 - Source not found
+ */
+app.delete("/:sourceId", async (req, res) => {
+ try {
+ const result = await deleteSource(parseInt(req.params.sourceId));
+ if (result?.code) return res.status(result.code).json(result);
+ res.json({ message: "Source deleted successfully" });
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * POST /sources/:sourceId/sync
+ * @summary Sync Source
+ * @description Manually triggers a sync for a specific source
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @param {number} sourceId.path.required - The source ID
+ * @return {object} 200 - Sync result
+ */
+app.post("/:sourceId/sync", async (req, res) => {
+ try {
+ const result = await syncSource(parseInt(req.params.sourceId));
+ if (result.success) {
+ res.json({ message: "Source synced successfully" });
+ } else {
+ res.status(400).json({ code: 400, message: result.error });
+ }
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+/**
+ * POST /sources/sync-all
+ * @summary Sync All Sources
+ * @description Manually triggers a sync for all enabled sources
+ * @tags Sources
+ * @produces application/json
+ * @security BearerAuth
+ * @return {object} 200 - Sync initiated
+ */
+app.post("/sync-all", async (req, res) => {
+ try {
+ syncAllSources().catch(err => {
+ console.error("Background sync failed:", err);
+ });
+ res.json({ message: "Sync initiated for all sources" });
+ } catch (error) {
+ res.status(500).json({ code: 500, message: error.message });
+ }
+});
+
+module.exports = app;
diff --git a/server/routes/term.js b/server/routes/term.js
index a6e770297..6f3434f95 100644
--- a/server/routes/term.js
+++ b/server/routes/term.js
@@ -1,12 +1,15 @@
const wsAuth = require("../middlewares/wsAuth");
const sshHook = require("../hooks/ssh");
+const scriptHook = require("../hooks/script");
const pveLxcHook = require("../hooks/pve-lxc");
const telnetHook = require("../hooks/telnet");
const { createTicket, getNodeForServer, openLXCConsole } = require("../controllers/pve");
const { createAuditLog, AUDIT_ACTIONS, RESOURCE_TYPES } = require("../controllers/audit");
const { getIntegrationCredentials } = require("../controllers/integration");
+const { getScript } = require("../controllers/script");
const { createSSHConnection } = require("../utils/sshConnection");
const logger = require("../utils/logger");
+const OrganizationMember = require("../models/OrganizationMember");
module.exports = async (ws, req) => {
const context = await wsAuth(ws, req);
@@ -28,22 +31,40 @@ module.exports = async (ws, req) => {
const isPveLxc = entry.type === "pve-lxc" || entry.type === "pve-shell";
if (isSSH) {
+ const scriptId = req.query.scriptId || (serverSession?.configuration?.scriptId);
+ let script = null;
+
+ if (scriptId) {
+ const memberships = await OrganizationMember.findAll({ where: { accountId: user.id } });
+ const organizationIds = memberships.map(m => m.organizationId);
+
+ script = await getScript(user.id, scriptId, null, organizationIds);
+ if (!script || script.code) {
+ ws.close(4010, script?.message || "Script not found");
+ return;
+ }
+ }
+
logger.verbose(`Initiating SSH connection`, {
entryId: entry.id,
entryName: entry.name,
target: entry.config.ip,
port: entry.config.port || 22,
identity: identity.name,
- user: user.username
+ user: user.username,
+ scriptMode: !!scriptId
});
auditLogId = await createAuditLog({
accountId: user.id,
organizationId: entry.organizationId,
- action: AUDIT_ACTIONS.SSH_CONNECT,
- resource: RESOURCE_TYPES.ENTRY,
- resourceId: entry.id,
- details: { connectionReason },
+ action: scriptId ? AUDIT_ACTIONS.SCRIPT_EXECUTE : AUDIT_ACTIONS.SSH_CONNECT,
+ resource: scriptId ? RESOURCE_TYPES.SCRIPT : RESOURCE_TYPES.ENTRY,
+ resourceId: scriptId || entry.id,
+ details: {
+ connectionReason,
+ ...(scriptId && { scriptName: script?.name, serverId: entry.id })
+ },
ipAddress,
userAgent,
});
@@ -67,11 +88,17 @@ module.exports = async (ws, req) => {
userId: user.id,
sourceIp: ipAddress,
jumpHosts: entry.config?.jumpHosts?.length || 0,
- reason: connectionReason || 'none'
+ reason: connectionReason || 'none',
+ scriptMode: !!scriptId
});
- hookContext = { ssh, auditLogId, serverSession };
- await sshHook(ws, hookContext);
+ hookContext = { ssh, auditLogId, serverSession, script, user };
+
+ if (script) {
+ await scriptHook(ws, hookContext);
+ } else {
+ await sshHook(ws, hookContext);
+ }
} else if (isTelnet) {
logger.verbose(`Initiating Telnet connection`, {
entryId: entry.id,
diff --git a/server/utils/apps/checkDistro.js b/server/utils/apps/checkDistro.js
deleted file mode 100644
index 3d38753a7..000000000
--- a/server/utils/apps/checkDistro.js
+++ /dev/null
@@ -1,37 +0,0 @@
-module.exports.checkDistro = (ssh, ws) => {
- return new Promise((resolve, reject) => {
- ssh.exec("cat /etc/os-release", (err, stream) => {
- if (err) return reject(new Error("Failed to check distro"));
-
- let data = "";
-
- stream.on("close", () => {
- let distro = null;
- let version = null;
-
- data.split("\n").forEach(line => {
- if (line.startsWith("ID=")) {
- distro = line.split("=")[1].replace(/"/g, "")
- .replace(/./, c => c.toUpperCase());
- }
- if (line.startsWith("VERSION_ID=")) {
- version = line.split("=")[1].replace(/"/g, "");
- }
- });
-
- if (distro && version) {
- ws.send(`\x021,${distro},${version}`);
- resolve();
- } else {
- reject(new Error("Failed to parse distro information"));
- }
- });
-
- stream.on("data", newData => {
- data += newData.toString();
- });
-
- stream.stderr.on("data", () => reject(new Error("Failed to check distro")));
- });
- });
-}
\ No newline at end of file
diff --git a/server/utils/apps/checkPermissions.js b/server/utils/apps/checkPermissions.js
deleted file mode 100644
index 70f262f41..000000000
--- a/server/utils/apps/checkPermissions.js
+++ /dev/null
@@ -1,53 +0,0 @@
-const checkSudoPermissions = (ssh, ws, identity) => {
- return new Promise((resolve, reject) => {
- ssh.exec("sudo -n true", (err, stream) => {
- if (err) return reject(new Error("Failed to check sudo permissions"));
-
- stream.on("data", () => {});
-
- stream.on("close", (code) => {
- if (code === 0) {
- ws.send(`\x022,Sudo access granted`);
- return resolve("sudo ");
- }
-
- ssh.exec(`echo ${identity.password} | sudo -S true`, (err, stream) => {
- if (err) return reject(new Error("Failed to check sudo permissions"));
-
- stream.on("data", () => {});
-
- stream.on("close", (code) => {
- if (code === 0) {
- ws.send(`\x022,Sudo access granted`);
- return resolve(`echo ${identity.password} | sudo -S `);
- }
-
- ws.send(`\x021,Sudo access denied`);
- reject(new Error("Sudo access denied"));
-
- });
- });
- });
- });
- });
-}
-
-module.exports.checkPermissions = (ssh, ws, identity) => {
- return new Promise((resolve, reject) => {
- ssh.exec("id -u", (err, stream) => {
- if (err) return reject(new Error("Failed to check permissions"));
-
- stream.on("data", data => {
- const userId = data.toString().trim();
- if (userId === "0") {
- ws.send(`\x022,Root permissions detected`);
- resolve("");
- } else {
- checkSudoPermissions(ssh, ws, identity).then(resolve).catch(reject);
- }
- });
-
- stream.stderr.on("data", err => reject(new Error("Failed to check permissions")));
- });
- });
-}
\ No newline at end of file
diff --git a/server/utils/apps/installDocker.js b/server/utils/apps/installDocker.js
deleted file mode 100644
index 7fd79d4a7..000000000
--- a/server/utils/apps/installDocker.js
+++ /dev/null
@@ -1,44 +0,0 @@
-module.exports.installDocker = (ssh, ws, cmdPrefix) => {
- return new Promise((resolve, reject) => {
- ssh.exec(`${cmdPrefix}docker --version && (${cmdPrefix}docker-compose --version || ${cmdPrefix}docker compose version)`, (err, stream) => {
- let dockerInstalled = false;
-
- stream.on("data", () => {
- dockerInstalled = true;
- ws.send("\x023,Docker and Docker Compose are already installed");
- resolve();
- });
-
- stream.stderr.on("data", () => {
- if (!dockerInstalled) {
- ssh.exec(`curl -fsSL https://get.docker.com | ${cmdPrefix}sh`, (err, stream) => {
- if (err) {
- return reject(new Error("Failed to install Docker using the installation script"));
- }
-
- stream.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- stream.on("close", () => {
- ssh.exec(`${cmdPrefix}docker --version && (${cmdPrefix}docker-compose --version || ${cmdPrefix}docker compose version)`, (err, stream) => {
- if (err) {
- return reject(new Error("Failed to verify Docker installation"));
- }
-
- stream.on("data", () => {
- ws.send("\x023,Docker and Docker Compose installed successfully");
- resolve();
- });
-
- stream.stderr.on("data", () => {
- reject(new Error("Docker or Docker Compose not installed correctly"));
- });
- });
- });
- });
- }
- });
- });
- });
-};
\ No newline at end of file
diff --git a/server/utils/apps/pullImage.js b/server/utils/apps/pullImage.js
deleted file mode 100644
index a39f1b3a7..000000000
--- a/server/utils/apps/pullImage.js
+++ /dev/null
@@ -1,94 +0,0 @@
-const { getComposeFile } = require("../../controllers/appSource");
-
-const parseDockerPullOutput = (progress, line) => {
- const lines = line.split("\n");
-
- for (const line of lines) {
- if (line.trim() === "") continue;
-
- const parts = line.trim().split(" ");
- const layerId = parts[0];
- const status = parts[1];
- const progressPercent = parseProgress(parts[parts.length - 1]);
-
- if (status === "Downloading") {
- progress[layerId] = Math.round(progressPercent * 50);
- } else if (status === "Extracting") {
- progress[layerId] = 50 + Math.round(progressPercent * 50);
- }
- }
-};
-
-const parseProgress = (progressString) => {
- const [downloaded, total] = progressString.split("/").map(convertToBytes);
- return downloaded / total;
-};
-
-const convertToBytes = (sizeString) => {
- const number = parseFloat(sizeString);
- if (sizeString.endsWith("kB")) {
- return number * 1024;
- } else if (sizeString.endsWith("MB")) {
- return number * 1024 * 1024;
- } else {
- return number;
- }
-};
-
-module.exports.downloadBaseImage = (ssh, ws, appId, cmdPrefix) => {
- const folderAppId = appId.replace("/", "_");
-
- return new Promise((resolve, reject) => {
- ssh.exec(`${cmdPrefix}mkdir -p /opt/nexterm_apps/${folderAppId}`, (err, stream) => {
- if (err) return reject(new Error("Failed to create app folder"));
-
- stream.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- const fileContent = getComposeFile(appId);
- const escapedContent = fileContent.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
-
- stream.on("close", () => {
- ssh.exec(`${cmdPrefix} sh -c 'echo "${escapedContent}" > /opt/nexterm_apps/${folderAppId}/docker-compose.yml'`, (err, stream) => {
- if (err) return reject(new Error("Failed to write docker-compose file"));
-
- stream.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- this.pullImage(ssh, ws, appId, resolve, reject, true, cmdPrefix);
- });
- });
- });
- });
-};
-
-module.exports.pullImage = (ssh, ws, image, resolve, reject, useStandalone = true, cmdPrefix) => {
- ssh.exec(`${cmdPrefix}${useStandalone ? "docker-compose" : "docker compose"} -f /opt/nexterm_apps/${image.replace("/", "_")}/docker-compose.yml pull`, (err, stream) => {
- let layerProgress = {};
-
- stream.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- stream.on("close", (code) => {
- if (code !== 0 && !useStandalone) return reject(new Error("Failed to pull image"));
- if (code !== 0 && useStandalone) return this.pullImage(ssh, ws, image, resolve, reject, false, cmdPrefix);
-
- ws.send("\x024,Image pulled successfully");
- resolve();
- });
-
- stream.stderr.on("data", data => {
- ws.send("\x01" + data.toString());
- parseDockerPullOutput(layerProgress, data.toString());
-
- const totalProgress = Object.values(layerProgress).reduce((acc, curr) => acc + curr, 0) / Object.keys(layerProgress).length;
-
- if (!isNaN(totalProgress)) {
- ws.send(`\x04${Math.round(totalProgress)}`);
- }
- });
- });
-};
\ No newline at end of file
diff --git a/server/utils/apps/runCommand.js b/server/utils/apps/runCommand.js
deleted file mode 100644
index 313b6026a..000000000
--- a/server/utils/apps/runCommand.js
+++ /dev/null
@@ -1,29 +0,0 @@
-module.exports.runPreInstallCommand = (ssh, ws, preInstallCommand, cmdPrefix) => {
- return new Promise((resolve, reject) => {
- ssh.exec(cmdPrefix + preInstallCommand, (err, stream) => {
- if (err) return reject(new Error("Failed to run pre-install command"));
-
- stream.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- ws.send("\x025,Pre-install command completed");
- resolve();
- });
- });
-}
-
-module.exports.runPostInstallCommand = (ssh, ws, postInstallCommand, cmdPrefix) => {
- return new Promise((resolve, reject) => {
- ssh.exec(cmdPrefix + postInstallCommand, (err, stream) => {
- if (err) return reject(new Error("Failed to run post-install command"));
-
- stream.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- ws.send("\x027,Post-install command completed");
- resolve();
- });
- });
-}
\ No newline at end of file
diff --git a/server/utils/apps/startContainer.js b/server/utils/apps/startContainer.js
deleted file mode 100644
index 6a42db4a4..000000000
--- a/server/utils/apps/startContainer.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const logger = require("../logger");
-
-module.exports.startContainer = startContainer = (ssh, ws, appId, resolve, reject, useStandalone = true, cmdPrefix) => {
- if (!resolve || !reject) {
- return new Promise((resolve, reject) => {
- startContainer(ssh, ws, appId, resolve, reject, useStandalone, cmdPrefix);
- });
- }
-
- const command = `cd /opt/nexterm_apps/${appId.replace("/", "_")} && ${cmdPrefix}${useStandalone ? "docker-compose" : "docker compose"} up -d`;
-
- ssh.exec(command, (err, stream) => {
- if (err) {
- logger.verbose("Failed to execute container start command", { appId, error: err.message });
- return reject(new Error("Failed to start container"));
- }
- stream.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- stream.stderr.on("data", (data) => {
- ws.send("\x01" + data.toString());
- });
-
- stream.on("close", (code) => {
- if (code !== 0) {
- if (useStandalone) {
- return startContainer(ssh, ws, appId, resolve, reject, false, cmdPrefix);
- } else {
- return reject(new Error("Failed to start container"));
- }
- }
-
- ws.send("\x026,Container started");
- resolve();
- });
-
- stream.on("error", (streamErr) => {
- return reject(new Error(`Stream error: ${streamErr.message}`));
- });
- });
-};
diff --git a/server/utils/scriptUtils.js b/server/utils/scriptUtils.js
index 2f7096534..bce76f936 100644
--- a/server/utils/scriptUtils.js
+++ b/server/utils/scriptUtils.js
@@ -158,27 +158,61 @@ module.exports.transformScript = (scriptContent) => {
},
);
- return `#!/bin/bash
+ const scriptWithHeader = `#!/bin/bash
set -e
${transformedContent}
-exit 0
`;
+
+ const base64Script = Buffer.from(scriptWithHeader).toString("base64");
+
+ return `_script=$(mktemp) && echo '${base64Script}' | base64 -d > "$_script" && chmod +x "$_script" && "$_script"; _exit=$?; rm -f "$_script"; exit $_exit`;
+};
+
+module.exports.stripAnsi = (str) => {
+ return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
+};
+
+module.exports.findNextermCommand = (line) => {
+ const cleanLine = module.exports.stripAnsi(line);
+
+ if (cleanLine.match(/echo\s+["']?NEXTERM_/i)) {
+ return null;
+ }
+
+ const trimmedLine = cleanLine.trim();
+ if (trimmedLine.match(/^[$#>]\s+.*NEXTERM_/)) {
+ return null;
+ }
+
+ const match = cleanLine.match(/NEXTERM_(INPUT|SELECT|STEP|WARN|INFO|CONFIRM|PROGRESS|SUCCESS|SUMMARY|TABLE|MSGBOX):(.*)/s);
+ if (match) {
+ return {
+ command: `NEXTERM_${match[1]}`,
+ rest: match[2],
+ };
+ }
+ return null;
};
module.exports.processNextermLine = (line) => {
- if (line.startsWith("NEXTERM_INPUT:")) {
- const parts = line.substring(14).split(":");
+ const found = module.exports.findNextermCommand(line);
+ if (!found) return null;
+
+ const { command, rest } = found;
+
+ if (command === "NEXTERM_INPUT") {
+ const parts = rest.split(":");
const varName = parts[0];
- const prompt = module.exports.unescapeColons(parts[1]);
+ const prompt = module.exports.unescapeColons(parts[1] || "");
const defaultValue = parts[2] ? module.exports.unescapeColons(parts[2]) : "";
return { type: "input", variable: varName, prompt: prompt, default: defaultValue || "" };
}
- if (line.startsWith("NEXTERM_SELECT:")) {
- const parts = line.substring(15).split(":");
+ if (command === "NEXTERM_SELECT") {
+ const parts = rest.split(":");
const varName = parts[0];
- const prompt = module.exports.unescapeColons(parts[1]);
+ const prompt = module.exports.unescapeColons(parts[1] || "");
const optionsStr = parts.slice(2).join(":");
const unescapedOptionsStr = module.exports.unescapeColons(optionsStr);
const options = module.exports.parseOptions(unescapedOptionsStr);
@@ -186,57 +220,57 @@ module.exports.processNextermLine = (line) => {
return { type: "select", variable: varName, prompt: prompt, options: options, default: options[0] || "" };
}
- if (line.startsWith("NEXTERM_STEP:")) {
- const stepDesc = line.substring(13);
+ if (command === "NEXTERM_STEP") {
+ const stepDesc = rest.trim();
return { type: "step", description: stepDesc };
}
- if (line.startsWith("NEXTERM_WARN:")) {
- const message = module.exports.unescapeColons(line.substring(13));
+ if (command === "NEXTERM_WARN") {
+ const message = module.exports.unescapeColons(rest);
return { type: "warning", message: message };
}
- if (line.startsWith("NEXTERM_INFO:")) {
- const message = module.exports.unescapeColons(line.substring(13));
+ if (command === "NEXTERM_INFO") {
+ const message = module.exports.unescapeColons(rest);
return { type: "info", message: message };
}
- if (line.startsWith("NEXTERM_CONFIRM:")) {
- const message = module.exports.unescapeColons(line.substring(16));
+ if (command === "NEXTERM_CONFIRM") {
+ const message = module.exports.unescapeColons(rest);
return { type: "confirm", message: message };
}
- if (line.startsWith("NEXTERM_PROGRESS:")) {
- const [percentage] = line.substring(17).split(":");
+ if (command === "NEXTERM_PROGRESS") {
+ const [percentage] = rest.split(":");
return { type: "progress", percentage: parseInt(percentage) || 0 };
}
- if (line.startsWith("NEXTERM_SUCCESS:")) {
- const message = module.exports.unescapeColons(line.substring(16));
+ if (command === "NEXTERM_SUCCESS") {
+ const message = module.exports.unescapeColons(rest);
return { type: "success", message: message };
}
- if (line.startsWith("NEXTERM_SUMMARY:")) {
- const parts = line.substring(16).split(":");
- const title = module.exports.unescapeColons(parts[0]);
+ if (command === "NEXTERM_SUMMARY") {
+ const parts = rest.split(":");
+ const title = module.exports.unescapeColons(parts[0] || "");
const dataStr = parts.slice(1).join(":");
const unescapedDataStr = module.exports.unescapeColons(dataStr);
const data = module.exports.parseOptions(unescapedDataStr);
return { type: "summary", title: title, data: data };
}
- if (line.startsWith("NEXTERM_TABLE:")) {
- const parts = line.substring(14).split(":");
- const title = module.exports.unescapeColons(parts[0]);
+ if (command === "NEXTERM_TABLE") {
+ const parts = rest.split(":");
+ const title = module.exports.unescapeColons(parts[0] || "");
const dataStr = parts.slice(1).join(":");
const unescapedDataStr = module.exports.unescapeColons(dataStr);
const data = module.exports.parseOptions(unescapedDataStr);
return { type: "table", title: title, data: data };
}
- if (line.startsWith("NEXTERM_MSGBOX:")) {
- const parts = line.substring(15).split(":");
- const title = module.exports.unescapeColons(parts[0]);
+ if (command === "NEXTERM_MSGBOX") {
+ const parts = rest.split(":");
+ const title = module.exports.unescapeColons(parts[0] || "");
const message = module.exports.unescapeColons(parts.slice(1).join(":"));
return { type: "msgbox", title: title, message: message };
}
diff --git a/server/utils/sftpHelpers.js b/server/utils/sftpHelpers.js
index 4a77dd230..c07cb4a9b 100644
--- a/server/utils/sftpHelpers.js
+++ b/server/utils/sftpHelpers.js
@@ -87,6 +87,9 @@ const OPERATIONS = {
RENAME_FILE: 0x8,
ERROR: 0x9,
SEARCH_DIRECTORIES: 0xA,
+ RESOLVE_SYMLINK: 0xB,
+ READ_FILE: 0xC,
+ WRITE_FILE: 0xD,
};
module.exports = {
diff --git a/server/utils/sourceSyncService.js b/server/utils/sourceSyncService.js
new file mode 100644
index 000000000..566bad323
--- /dev/null
+++ b/server/utils/sourceSyncService.js
@@ -0,0 +1,42 @@
+const { syncAllSources, ensureDefaultSource } = require("../controllers/source");
+const logger = require("./logger");
+
+let syncInterval = null;
+const SYNC_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
+
+const startSourceSyncService = async () => {
+ logger.system("Starting source sync service (hourly interval)");
+
+ try {
+ await ensureDefaultSource();
+ } catch (error) {
+ logger.error("Failed to ensure default source", { error: error.message });
+ }
+
+ setTimeout(async () => {
+ try {
+ await syncAllSources();
+ } catch (error) {
+ logger.error("Initial source sync failed", { error: error.message });
+ }
+ }, 10000);
+
+ syncInterval = setInterval(async () => {
+ try {
+ logger.info("Running scheduled source sync");
+ await syncAllSources();
+ } catch (error) {
+ logger.error("Scheduled source sync failed", { error: error.message });
+ }
+ }, SYNC_INTERVAL_MS);
+};
+
+const stopSourceSyncService = () => {
+ if (syncInterval) {
+ clearInterval(syncInterval);
+ syncInterval = null;
+ logger.system("Source sync service stopped");
+ }
+};
+
+module.exports = { startSourceSyncService, stopSourceSyncService };
diff --git a/server/utils/sshEventHandlers.js b/server/utils/sshEventHandlers.js
new file mode 100644
index 000000000..8a6612814
--- /dev/null
+++ b/server/utils/sshEventHandlers.js
@@ -0,0 +1,49 @@
+const { updateAuditLogWithSessionDuration } = require("../controllers/audit");
+const SessionManager = require("../lib/SessionManager");
+
+const parseResizeMessage = (message) => {
+ if (!message.startsWith?.("\x01")) return null;
+
+ const resizeData = message.substring(1);
+ if (!resizeData.includes(",")) return null;
+
+ const [width, height] = resizeData.split(",").map(Number);
+ if (isNaN(width) || isNaN(height)) return null;
+
+ return { width, height };
+};
+
+const setupSSHEventHandlers = (ssh, ws, options) => {
+ const { auditLogId, serverSession, connectionStartTime } = options;
+
+ ssh.on("error", (error) => {
+ const errorMsg = error.level === "client-timeout"
+ ? "Client Timeout reached"
+ : `SSH Error: ${error.message}`;
+ ws.close(error.level === "client-timeout" ? 4007 : 4005, errorMsg);
+ if (serverSession) SessionManager.remove(serverSession.sessionId);
+ });
+
+ ssh.on("end", async () => {
+ await updateAuditLogWithSessionDuration(auditLogId, connectionStartTime);
+ ws.close(4006, "Connection closed");
+ if (serverSession) SessionManager.remove(serverSession.sessionId);
+ });
+
+ ssh.on("exit", async () => {
+ await updateAuditLogWithSessionDuration(auditLogId, connectionStartTime);
+ ws.close(4006, "Connection exited");
+ if (serverSession) SessionManager.remove(serverSession.sessionId);
+ });
+
+ ssh.on("close", async () => {
+ await updateAuditLogWithSessionDuration(auditLogId, connectionStartTime);
+ if (ssh._jumpConnections) {
+ ssh._jumpConnections.forEach(conn => conn.ssh.end());
+ }
+ ws.close(4007, "Connection closed");
+ if (serverSession) SessionManager.remove(serverSession.sessionId);
+ });
+};
+
+module.exports = { parseResizeMessage, setupSSHEventHandlers };
\ No newline at end of file
diff --git a/server/validations/appSource.js b/server/validations/appSource.js
deleted file mode 100644
index d73e6aac4..000000000
--- a/server/validations/appSource.js
+++ /dev/null
@@ -1,21 +0,0 @@
-const Joi = require('joi');
-
-module.exports.appObject = Joi.object({
- name: Joi.string().required(),
- version: Joi.string().required(),
- description: Joi.string().required(),
- icon: Joi.string().uri().required(),
- preInstallCommand: Joi.string(),
- postInstallCommand: Joi.string(),
- category: Joi.string().required(),
- port: Joi.number().required()
-});
-
-module.exports.createAppSourceValidation = Joi.object({
- name: Joi.string().alphanum().required(),
- url: Joi.string().uri().regex(/\.zip$/).required()
-});
-
-module.exports.updateAppUrlValidation = Joi.object({
- url: Joi.string().uri().regex(/\.zip$/).required()
-});
\ No newline at end of file
diff --git a/server/validations/script.js b/server/validations/script.js
index 440dc31e0..2f3b54456 100644
--- a/server/validations/script.js
+++ b/server/validations/script.js
@@ -1,7 +1,14 @@
const Joi = require("joi");
-module.exports.scriptValidation = Joi.object({
- name: Joi.string().required().min(1).max(100),
- description: Joi.string().required().min(1).max(500),
- content: Joi.string().required().min(1)
+module.exports.scriptCreationValidation = Joi.object({
+ name: Joi.string().min(1).max(255).required(),
+ content: Joi.string().min(1).required(),
+ description: Joi.string().allow(null, ""),
+ organizationId: Joi.number().integer().allow(null),
+});
+
+module.exports.scriptEditValidation = Joi.object({
+ name: Joi.string().min(1).max(255),
+ content: Joi.string().min(1),
+ description: Joi.string().allow(null, "")
});
\ No newline at end of file
diff --git a/server/validations/serverSession.js b/server/validations/serverSession.js
index 6e7c24a2c..152eb2830 100644
--- a/server/validations/serverSession.js
+++ b/server/validations/serverSession.js
@@ -7,6 +7,7 @@ module.exports.createSessionValidation = Joi.object({
type: Joi.string().allow(null).optional(),
tabId: Joi.string().allow(null).optional(),
browserId: Joi.string().allow(null).optional(),
+ scriptId: Joi.number().allow(null).optional(),
directIdentity: Joi.object({
username: Joi.string().max(255).optional(),
type: Joi.string().valid("password", "ssh").required(),
diff --git a/server/validations/snippet.js b/server/validations/snippet.js
index e175795c3..10534cb58 100644
--- a/server/validations/snippet.js
+++ b/server/validations/snippet.js
@@ -4,6 +4,7 @@ module.exports.snippetCreationValidation = Joi.object({
name: Joi.string().min(1).max(255).required(),
command: Joi.string().min(1).required(),
description: Joi.string().allow(null, ""),
+ organizationId: Joi.number().integer().allow(null),
});
module.exports.snippetEditValidation = Joi.object({
diff --git a/server/validations/source.js b/server/validations/source.js
new file mode 100644
index 000000000..e9b02cf5f
--- /dev/null
+++ b/server/validations/source.js
@@ -0,0 +1,18 @@
+const Joi = require("joi");
+
+const sourceCreationValidation = Joi.object({
+ name: Joi.string().min(1).max(255).required(),
+ url: Joi.string().uri().required(),
+});
+
+const sourceUpdateValidation = Joi.object({
+ name: Joi.string().min(1).max(255),
+ url: Joi.string().uri(),
+ enabled: Joi.boolean(),
+});
+
+const validateUrlValidation = Joi.object({
+ url: Joi.string().uri().required(),
+});
+
+module.exports = { sourceCreationValidation, sourceUpdateValidation, validateUrlValidation };