mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-09 13:58:25 +02:00
Access control for proxy
Vim suggestions for proxy yaml Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
+162
-56
@@ -14,6 +14,7 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -266,6 +267,13 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ
|
||||
}
|
||||
}
|
||||
|
||||
// check access control before proceeding
|
||||
hasSession := reqCtx.SessionID != ""
|
||||
|
||||
if allowed, denyAction := m.evaluatePathAccess(req.URL.Path, reqCtx, hasSession); !allowed {
|
||||
return req, m.createDenyResponse(req, reqCtx, denyAction, hasSession)
|
||||
}
|
||||
|
||||
// handle requests without session
|
||||
if reqCtx.SessionID == "" && !createSession {
|
||||
return m.prepareRequestWithoutSession(req, reqCtx.TargetDomain), nil
|
||||
@@ -1785,11 +1793,7 @@ func (m *ProxyHandler) createCampaignFlowRedirect(session *ProxySession, resp *h
|
||||
}
|
||||
|
||||
redirectResp := &http.Response{
|
||||
Status: "302 Found",
|
||||
StatusCode: 302,
|
||||
Proto: resp.Proto,
|
||||
ProtoMajor: resp.ProtoMajor,
|
||||
ProtoMinor: resp.ProtoMinor,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewReader([]byte{})),
|
||||
Request: resp.Request,
|
||||
@@ -1975,49 +1979,6 @@ func (m *ProxyHandler) setProxyConfigDefaults(config *service.ProxyServiceConfig
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) ValidateProxyConfig(configStr string) (*service.ProxyServiceConfigYAML, error) {
|
||||
config, err := m.parseProxyConfig(configStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse proxy config: %w", err)
|
||||
}
|
||||
|
||||
if err := service.ValidateVersion(config); err != nil {
|
||||
return nil, fmt.Errorf("version validation failed: %w", err)
|
||||
}
|
||||
|
||||
for originalHost, hostConfig := range config.Hosts {
|
||||
if hostConfig == nil || hostConfig.To == "" {
|
||||
return nil, fmt.Errorf("domain mapping for '%s' is empty", originalHost)
|
||||
}
|
||||
|
||||
for i, capture := range hostConfig.Capture {
|
||||
if capture.Name == "" {
|
||||
return nil, fmt.Errorf("capture rule %d for '%s' has no name", i, originalHost)
|
||||
}
|
||||
if capture.Find == "" {
|
||||
return nil, fmt.Errorf("capture rule '%s' for '%s' has no pattern", capture.Name, originalHost)
|
||||
}
|
||||
if _, err := regexp.Compile(capture.Find); err != nil {
|
||||
return nil, fmt.Errorf("capture rule '%s' for '%s' has invalid regex: %w", capture.Name, originalHost, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i, replace := range hostConfig.Rewrite {
|
||||
if replace.Name == "" {
|
||||
return nil, fmt.Errorf("replace rule %d for '%s' has no name", i, originalHost)
|
||||
}
|
||||
if replace.Find == "" {
|
||||
return nil, fmt.Errorf("replace rule '%s' for '%s' has no find pattern", replace.Name, originalHost)
|
||||
}
|
||||
if _, err := regexp.Compile(replace.Find); err != nil {
|
||||
return nil, fmt.Errorf("replace rule '%s' for '%s' has invalid regex: %w", replace.Name, originalHost, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) GetCookieName() string {
|
||||
return m.cookieName
|
||||
}
|
||||
@@ -2104,10 +2065,6 @@ func (m *ProxyHandler) configToMap(configMap *sync.Map) map[string]service.Proxy
|
||||
func (m *ProxyHandler) createServiceUnavailableResponse(message string) *http.Response {
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Status: "503 Service Unavailable",
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(message)),
|
||||
}
|
||||
@@ -2116,10 +2073,19 @@ func (m *ProxyHandler) createServiceUnavailableResponse(message string) *http.Re
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response) error {
|
||||
// check for nil response
|
||||
if resp == nil {
|
||||
m.logger.Errorw("response is nil in writeResponse")
|
||||
w.WriteHeader(500)
|
||||
return errors.New("response is nil")
|
||||
}
|
||||
|
||||
// copy headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
if resp.Header != nil {
|
||||
for k, v := range resp.Header {
|
||||
for _, val := range v {
|
||||
w.Header().Add(k, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2127,6 +2093,146 @@ func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// copy body
|
||||
_, err := io.Copy(w, resp.Body)
|
||||
return err
|
||||
if resp.Body != nil {
|
||||
_, err := io.Copy(w, resp.Body)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluatePathAccess checks if a path is allowed based on access control rules
|
||||
func (m *ProxyHandler) evaluatePathAccess(path string, reqCtx *RequestContext, hasSession bool) (bool, string) {
|
||||
// check for nil request context
|
||||
if reqCtx == nil {
|
||||
m.logger.Errorw("request context is nil in evaluatePathAccess")
|
||||
return true, "" // default allow to prevent panic
|
||||
}
|
||||
|
||||
// check domain-specific rules first
|
||||
if reqCtx.Domain != nil && reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Hosts != nil {
|
||||
|
||||
// find the domain config where the "to" field matches our phishing domain
|
||||
for _, domainConfig := range reqCtx.ProxyConfig.Hosts {
|
||||
if domainConfig != nil && domainConfig.To == reqCtx.PhishDomain && domainConfig.Access != nil {
|
||||
allowed, action := m.checkAccessRules(path, domainConfig.Access, hasSession)
|
||||
// domain rule found - return its decision (allow or deny)
|
||||
return allowed, action
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// no domain rule found - check global rules
|
||||
if reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.Access != nil {
|
||||
|
||||
if allowed, action := m.checkAccessRules(path, reqCtx.ProxyConfig.Global.Access, hasSession); !allowed {
|
||||
|
||||
return false, action
|
||||
}
|
||||
}
|
||||
|
||||
// default allow if no rules match
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// checkAccessRules evaluates access control rules for a given path
|
||||
func (m *ProxyHandler) checkAccessRules(path string, accessControl *service.ProxyServiceAccessControl, hasSession bool) (bool, string) {
|
||||
if accessControl == nil {
|
||||
return true, "" // no access control = allow everything
|
||||
}
|
||||
|
||||
matches := m.matchesAnyAccessPath(path, accessControl.Paths)
|
||||
|
||||
var action string
|
||||
if hasSession {
|
||||
action = accessControl.OnDeny.WithSession
|
||||
} else {
|
||||
action = accessControl.OnDeny.WithoutSession
|
||||
}
|
||||
|
||||
// default actions if not specified
|
||||
if action == "" {
|
||||
action = "404"
|
||||
}
|
||||
|
||||
switch accessControl.Mode {
|
||||
case "allow":
|
||||
if matches {
|
||||
return true, "" // path matches allow list
|
||||
}
|
||||
// path doesn't match allow list - check if we should allow anyway
|
||||
if action == "allow" {
|
||||
return true, "" // override deny with allow
|
||||
}
|
||||
return false, action // deny with specified action
|
||||
case "deny":
|
||||
if !matches {
|
||||
return true, "" // path doesn't match deny list
|
||||
}
|
||||
// path matches deny list - check if we should allow anyway
|
||||
if action == "allow" {
|
||||
return true, "" // override deny with allow
|
||||
}
|
||||
return false, action // deny with specified action
|
||||
default:
|
||||
return true, "" // safe default
|
||||
}
|
||||
}
|
||||
|
||||
// matchesAnyAccessPath checks if a path matches any of the provided regex patterns
|
||||
func (m *ProxyHandler) matchesAnyAccessPath(path string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if matched, err := regexp.MatchString(pattern, path); err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// createDenyResponse creates an appropriate response for denied access
|
||||
func (m *ProxyHandler) createDenyResponse(req *http.Request, reqCtx *RequestContext, denyAction string, hasSession bool) *http.Response {
|
||||
// construct proper full URL for logging
|
||||
fullURL := fmt.Sprintf("%s://%s%s", req.URL.Scheme, req.Host, req.URL.RequestURI())
|
||||
|
||||
// log the denial for debugging
|
||||
m.logger.Debugw("access denied for path",
|
||||
"path", req.URL.Path,
|
||||
"full_url", fullURL,
|
||||
"phish_domain", reqCtx.PhishDomain,
|
||||
"target_domain", reqCtx.TargetDomain,
|
||||
"has_session", hasSession,
|
||||
"deny_action", denyAction,
|
||||
"user_agent", req.Header.Get("User-Agent"),
|
||||
)
|
||||
|
||||
if strings.HasPrefix(denyAction, "redirect:") {
|
||||
url := strings.TrimPrefix(denyAction, "redirect:")
|
||||
return m.createRedirectResponse(url)
|
||||
}
|
||||
|
||||
// parse as status code
|
||||
if statusCode, err := strconv.Atoi(denyAction); err == nil {
|
||||
return m.createStatusResponse(statusCode)
|
||||
}
|
||||
|
||||
return m.createStatusResponse(404) // fallback
|
||||
}
|
||||
|
||||
// createRedirectResponse creates a redirect response
|
||||
func (m *ProxyHandler) createRedirectResponse(url string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: 302,
|
||||
Header: map[string][]string{
|
||||
"Location": {url},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createStatusResponse creates a response with the specified status code
|
||||
func (m *ProxyHandler) createStatusResponse(statusCode int) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Header: make(map[string][]string),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}
|
||||
}
|
||||
|
||||
+164
-7
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -39,16 +40,31 @@ type ProxyServiceConfig struct {
|
||||
|
||||
// ProxyServiceDomainConfig represents configuration for a specific domain mapping
|
||||
type ProxyServiceDomainConfig struct {
|
||||
To string `yaml:"to"`
|
||||
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
|
||||
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
|
||||
To string `yaml:"to"`
|
||||
Access *ProxyServiceAccessControl `yaml:"access,omitempty"`
|
||||
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
|
||||
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
|
||||
}
|
||||
|
||||
// ProxyServiceRules represents capture and replace rules
|
||||
// ProxyServiceRules represents global rules that apply to all hosts
|
||||
type ProxyServiceRules struct {
|
||||
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
|
||||
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
|
||||
Access *ProxyServiceAccessControl `yaml:"access,omitempty"`
|
||||
Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"`
|
||||
Rewrite []ProxyServiceReplaceRule `yaml:"rewrite,omitempty"`
|
||||
}
|
||||
|
||||
// ProxyServiceAccessControl represents access control configuration
|
||||
type ProxyServiceAccessControl struct {
|
||||
Mode string `yaml:"mode"` // "allow" | "deny"
|
||||
Paths []string `yaml:"paths"`
|
||||
OnDeny ProxyServiceDenyResponse `yaml:"on_deny"`
|
||||
}
|
||||
|
||||
// ProxyServiceDenyResponse represents response configuration when access is denied
|
||||
type ProxyServiceDenyResponse struct {
|
||||
WithSession string `yaml:"with_session"` // "allow" | "redirect:URL" | status code
|
||||
WithoutSession string `yaml:"without_session"` // "allow" | "redirect:URL" | status code
|
||||
}
|
||||
|
||||
// CompilePathPatterns compiles regex patterns for all capture rules
|
||||
@@ -107,6 +123,43 @@ type ProxyServiceReplaceRule struct {
|
||||
}
|
||||
|
||||
// ProxyServiceConfigYAML represents the complete YAML configuration structure that matches the actual YAML format
|
||||
//
|
||||
// Example YAML configuration with access control:
|
||||
//
|
||||
// version: "0.0"
|
||||
// global:
|
||||
//
|
||||
// access:
|
||||
// mode: "deny"
|
||||
// paths:
|
||||
// - "^/admin/"
|
||||
// - "^/wp-admin/"
|
||||
// - "^/\\.git/"
|
||||
// on_deny:
|
||||
// with_session: 403
|
||||
// without_session: 404
|
||||
// capture:
|
||||
// - name: "global_navigation"
|
||||
// path: "/important"
|
||||
//
|
||||
// example.com:
|
||||
//
|
||||
// to: "phishing-example.com"
|
||||
// access:
|
||||
// mode: "allow"
|
||||
// paths:
|
||||
// - "^/login"
|
||||
// - "^/api/public/"
|
||||
// - "^/assets/"
|
||||
// on_deny:
|
||||
// with_session: "redirect:https://phishing-example.com/"
|
||||
// without_session: 503
|
||||
// capture:
|
||||
// - name: "login_capture"
|
||||
// method: "POST"
|
||||
// path: "/login"
|
||||
// find: "password=(.*?)&"
|
||||
// from: "request_body"
|
||||
type ProxyServiceConfigYAML struct {
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Proxy string `yaml:"proxy,omitempty"`
|
||||
@@ -498,6 +551,11 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P
|
||||
)
|
||||
}
|
||||
|
||||
// validate domain-specific access control
|
||||
if err := m.validateAccessControl(domainConfig.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// validate domain-specific capture rules
|
||||
if err := m.validateCaptureRules(domainConfig.Capture); err != nil {
|
||||
return err
|
||||
@@ -512,8 +570,11 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P
|
||||
// the syncProxyDomains method will handle domain management properly
|
||||
}
|
||||
|
||||
// validate global capture and rewrite rules
|
||||
// validate global rules
|
||||
if config.Global != nil {
|
||||
if err := m.validateAccessControl(config.Global.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateCaptureRules(config.Global.Capture); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -701,6 +762,94 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAccessControl validates access control configuration
|
||||
func (m *Proxy) validateAccessControl(accessControl *ProxyServiceAccessControl) error {
|
||||
if accessControl == nil {
|
||||
return nil // access control is optional
|
||||
}
|
||||
|
||||
// validate mode
|
||||
if accessControl.Mode != "allow" && accessControl.Mode != "deny" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("access control mode must be either 'allow' or 'deny'"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
|
||||
// validate paths are valid regex patterns
|
||||
for i, path := range accessControl.Paths {
|
||||
if path == "" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New(fmt.Sprintf("access control path %d cannot be empty", i)),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
if _, err := regexp.Compile(path); err != nil {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New(fmt.Sprintf("invalid regex pattern in access control path '%s': %s", path, err.Error())),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// validate deny response actions
|
||||
if err := m.validateDenyAction(accessControl.OnDeny.WithSession, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateDenyAction(accessControl.OnDeny.WithoutSession, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDenyAction validates a deny action string
|
||||
func (m *Proxy) validateDenyAction(action string, withSession bool) error {
|
||||
if action == "" {
|
||||
return nil // action is optional, will use default
|
||||
}
|
||||
|
||||
// check for allow action
|
||||
if action == "allow" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check for redirect action
|
||||
if strings.HasPrefix(action, "redirect:") {
|
||||
url := strings.TrimPrefix(action, "redirect:")
|
||||
if url == "" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("redirect action must include URL: 'redirect:https://example.com'"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
// basic URL validation
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("redirect URL must start with http:// or https://"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// check for status code
|
||||
if statusCode, err := strconv.Atoi(action); err == nil {
|
||||
if statusCode < 100 || statusCode > 599 {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("status code must be between 100 and 599"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("invalid deny action: must be 'allow', 'redirect:URL', or a status code"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
|
||||
// validateProxyConfig validates Proxy configuration
|
||||
func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) error {
|
||||
// validate Proxy configuration YAML
|
||||
@@ -830,6 +979,11 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err
|
||||
}
|
||||
}
|
||||
|
||||
// validate domain-specific access control
|
||||
if err := m.validateAccessControl(domainConfig.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// validate domain-specific capture rules
|
||||
if err := m.validateCaptureRules(domainConfig.Capture); err != nil {
|
||||
return err
|
||||
@@ -846,8 +1000,11 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err
|
||||
}
|
||||
}
|
||||
|
||||
// validate global capture and rewrite rules
|
||||
// validate global rules
|
||||
if config.Global != nil {
|
||||
if err := m.validateAccessControl(config.Global.Access); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateCaptureRules(config.Global.Capture); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||
import * as vimModule from 'monaco-vim';
|
||||
import { BiMap } from '$lib/utils/maps';
|
||||
import { previewQR as generateQR } from '$lib/utils/qrPreview';
|
||||
import { vimModeEnabled } from '$lib/store/vimMode.js';
|
||||
@@ -32,6 +33,8 @@
|
||||
let fileInputRef;
|
||||
let shadowContainer = null;
|
||||
let vimStatusBar = null;
|
||||
let isDestroyed = false;
|
||||
let editorContainer = null;
|
||||
|
||||
const apiTemplates = [
|
||||
{ label: 'Custom Field 1', text: '{{.CustomField1}}' },
|
||||
@@ -101,15 +104,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
$: {
|
||||
|
||||
if (previewFrame && isPreviewVisible && !isRenderingPreview) {
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
onMount(() => {
|
||||
document.body.classList.add('overflow-hidden');
|
||||
/* @ts-ignore */
|
||||
@@ -121,15 +115,26 @@
|
||||
return new editorWorker();
|
||||
}
|
||||
};
|
||||
editor = monaco.editor.create(document.getElementById('monaco-editor'), {
|
||||
const editorOptions = {
|
||||
value: value,
|
||||
language: 'html',
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
folding: true,
|
||||
wordWrap: 'on',
|
||||
contextmenu: true,
|
||||
scrollbar: {
|
||||
horizontal: 'hidden'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/* @ts-ignore - editorOptions is not complete */
|
||||
editor = monaco.editor.create(editorContainer, editorOptions);
|
||||
|
||||
// vim mode will be initialized by reactive statement if needed
|
||||
|
||||
@@ -145,12 +150,14 @@
|
||||
updatePreview();
|
||||
|
||||
return () => {
|
||||
isDestroyed = true;
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
// properly cleanup vim mode first
|
||||
destroyVimMode();
|
||||
if (editor) {
|
||||
editor.dispose();
|
||||
monaco.editor.getModels().forEach((model) => model.dispose());
|
||||
editor = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -159,18 +166,16 @@
|
||||
let vimModeInstance = null;
|
||||
|
||||
const initializeVimMode = () => {
|
||||
if (localVimMode && editor && !vimModeInstance) {
|
||||
import('monaco-vim')
|
||||
.then((vimModule) => {
|
||||
const statusNode = vimStatusBar;
|
||||
vimModeInstance = vimModule.initVimMode(editor, statusNode);
|
||||
if (localVimMode && editor && !vimModeInstance && !isDestroyed) {
|
||||
try {
|
||||
const statusNode = vimStatusBar;
|
||||
vimModeInstance = vimModule.initVimMode(editor, statusNode);
|
||||
|
||||
// integrate system clipboard with vim registers
|
||||
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('vim mode not available', e);
|
||||
});
|
||||
// integrate system clipboard with vim registers
|
||||
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
|
||||
} catch (e) {
|
||||
console.error('vim mode not available', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -186,11 +191,6 @@
|
||||
console.warn('Error disposing vim mode:', e);
|
||||
}
|
||||
|
||||
// clear vim status bar
|
||||
if (vimStatusBar) {
|
||||
vimStatusBar.textContent = '';
|
||||
}
|
||||
|
||||
vimModeInstance = null;
|
||||
}
|
||||
};
|
||||
@@ -202,22 +202,18 @@
|
||||
localVimMode = $vimModeEnabled;
|
||||
}
|
||||
|
||||
// debounce vim mode changes to prevent race conditions
|
||||
let vimModeTimeout = null;
|
||||
|
||||
// Watch for vim mode changes and editor initialization
|
||||
$: if (editor && typeof localVimMode === 'boolean') {
|
||||
if (vimModeTimeout) {
|
||||
clearTimeout(vimModeTimeout);
|
||||
// Watch for vim mode changes
|
||||
$: if (editor && !isDestroyed && typeof localVimMode === 'boolean') {
|
||||
if (localVimMode && !vimModeInstance) {
|
||||
// Wait for DOM updates to complete
|
||||
tick().then(() => {
|
||||
if (localVimMode && !vimModeInstance && !isDestroyed) {
|
||||
initializeVimMode();
|
||||
}
|
||||
});
|
||||
} else if (!localVimMode && vimModeInstance) {
|
||||
destroyVimMode();
|
||||
}
|
||||
vimModeTimeout = setTimeout(() => {
|
||||
if (localVimMode && !vimModeInstance) {
|
||||
initializeVimMode();
|
||||
} else if (!localVimMode && vimModeInstance) {
|
||||
destroyVimMode();
|
||||
}
|
||||
vimModeTimeout = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const selectPreviewDomain = () => {
|
||||
@@ -619,11 +615,19 @@
|
||||
updatePreview();
|
||||
}
|
||||
}}
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
class="h-8 border-2 rounded-md w-36 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
|
||||
class:font-bold={isPreviewVisible}
|
||||
class:bg-cta-blue={isPreviewVisible}
|
||||
class:dark:bg-indigo-600={isPreviewVisible}
|
||||
class:bg-blue-600={isPreviewVisible}
|
||||
class:dark:bg-blue-500={isPreviewVisible}
|
||||
class:text-white={isPreviewVisible}
|
||||
class:border-blue-600={isPreviewVisible}
|
||||
class:dark:border-blue-500={isPreviewVisible}
|
||||
class:text-gray-700={!isPreviewVisible}
|
||||
class:dark:text-gray-200={!isPreviewVisible}
|
||||
class:bg-white={!isPreviewVisible}
|
||||
class:dark:bg-gray-700={!isPreviewVisible}
|
||||
class:border-gray-300={!isPreviewVisible}
|
||||
class:dark:border-gray-600={!isPreviewVisible}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -647,18 +651,25 @@
|
||||
on:click={() => {
|
||||
vimModeEnabled.update((v) => !v);
|
||||
}}
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
class="h-8 border-2 rounded-md w-36 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
|
||||
class:font-bold={localVimMode}
|
||||
class:bg-cta-blue={localVimMode}
|
||||
class:dark:bg-indigo-600={localVimMode}
|
||||
class:bg-blue-600={localVimMode}
|
||||
class:dark:bg-blue-500={localVimMode}
|
||||
class:text-white={localVimMode}
|
||||
class:border-blue-600={localVimMode}
|
||||
class:dark:border-blue-500={localVimMode}
|
||||
class:text-gray-700={!localVimMode}
|
||||
class:dark:text-gray-200={!localVimMode}
|
||||
class:bg-white={!localVimMode}
|
||||
class:dark:bg-gray-700={!localVimMode}
|
||||
class:border-gray-300={!localVimMode}
|
||||
class:dark:border-gray-600={!localVimMode}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="transition-colors duration-200"
|
||||
>
|
||||
<path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z" />
|
||||
</svg>
|
||||
@@ -740,23 +751,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex h-full">
|
||||
<div class="flex h-full rounded-lg overflow-hidden">
|
||||
<div
|
||||
class="flex flex-col border-2 border-black dark:border-gray-600 bg-white dark:bg-gray-900 {!isPreviewVisible
|
||||
class="flex flex-col relative {!isPreviewVisible
|
||||
? 'w-80vw'
|
||||
: 'w-1/2'} transition-colors duration-200"
|
||||
class:h-55vh={isDetailsVisible}
|
||||
class:h-67vh={!isDetailsVisible}
|
||||
>
|
||||
<div
|
||||
id="monaco-editor"
|
||||
class="h-full"
|
||||
style={localVimMode ? 'height: calc(100% - 25px)' : ''}
|
||||
/>
|
||||
<div bind:this={editorContainer} class="h-full"></div>
|
||||
{#if localVimMode}
|
||||
<div
|
||||
bind:this={vimStatusBar}
|
||||
class="px-2 py-1 bg-gray-100 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300"
|
||||
class="absolute bottom-0 left-0 right-0 px-2 py-1 bg-gray-700 text-xs font-mono text-gray-300"
|
||||
style="height: 25px;"
|
||||
></div>
|
||||
{/if}
|
||||
@@ -776,3 +783,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.monaco-editor .current-line) {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
import * as vimModule from 'monaco-vim';
|
||||
|
||||
import { vimModeEnabled } from '$lib/store/vimMode.js';
|
||||
import {
|
||||
setupVimClipboardIntegration,
|
||||
destroyVimClipboardIntegration
|
||||
} from '$lib/utils/vimClipboard.js';
|
||||
import { setupProxyYamlCompletion } from '$lib/utils/proxyYamlCompletion.js';
|
||||
|
||||
export let value = '';
|
||||
export let height = 'medium';
|
||||
export let language = 'json';
|
||||
export let placeholder = '';
|
||||
export let showVimToggle = true;
|
||||
export let enableProxyCompletion = false; // enable proxy YAML completion
|
||||
export let externalVimMode = null; // allow external control of vim mode
|
||||
let localVimMode = externalVimMode !== null ? externalVimMode : $vimModeEnabled;
|
||||
|
||||
@@ -22,6 +26,8 @@
|
||||
let isDark = false;
|
||||
let vimStatusBar = null;
|
||||
let vimModeInstance = null;
|
||||
let proxyCompletionProvider = null;
|
||||
let isDestroyed = false;
|
||||
|
||||
const heightClasses = {
|
||||
small: 'h-64',
|
||||
@@ -56,9 +62,13 @@
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
isDestroyed = true;
|
||||
observer.disconnect();
|
||||
// properly cleanup vim mode first
|
||||
destroyVimMode();
|
||||
if (editor) {
|
||||
editor.dispose();
|
||||
editor = null;
|
||||
}
|
||||
};
|
||||
/* @ts-ignore */
|
||||
@@ -71,7 +81,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
editor = monaco.editor.create(editorContainer, {
|
||||
const editorOptions = {
|
||||
value: value || '',
|
||||
language: language,
|
||||
theme: 'vs-dark',
|
||||
@@ -88,15 +98,35 @@
|
||||
scrollbar: {
|
||||
horizontal: 'hidden'
|
||||
},
|
||||
quickSuggestions: false,
|
||||
// Enable suggestions for YAML with proxy completion
|
||||
quickSuggestions: enableProxyCompletion && language === 'yaml' ? true : false,
|
||||
parameterHints: {
|
||||
enabled: false
|
||||
enabled: enableProxyCompletion && language === 'yaml'
|
||||
},
|
||||
suggestOnTriggerCharacters: false,
|
||||
acceptSuggestionOnEnter: 'off',
|
||||
tabCompletion: 'off',
|
||||
wordBasedSuggestions: 'off'
|
||||
});
|
||||
suggestOnTriggerCharacters: enableProxyCompletion && language === 'yaml',
|
||||
acceptSuggestionOnEnter: enableProxyCompletion && language === 'yaml' ? 'on' : 'off',
|
||||
tabCompletion: enableProxyCompletion && language === 'yaml' ? 'on' : 'off',
|
||||
wordBasedSuggestions:
|
||||
enableProxyCompletion && language === 'yaml' ? 'currentDocument' : 'off',
|
||||
// Better YAML editing
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
detectIndentation: false,
|
||||
trimAutoWhitespace: true,
|
||||
// Bracket matching
|
||||
matchBrackets: 'always',
|
||||
// Selection
|
||||
selectOnLineNumbers: true,
|
||||
// Find
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection'
|
||||
}
|
||||
};
|
||||
|
||||
/* @ts-ignore - editorOptions is not complete */
|
||||
editor = monaco.editor.create(editorContainer, editorOptions);
|
||||
|
||||
// vim mode will be initialized by reactive statement if needed
|
||||
|
||||
@@ -105,10 +135,22 @@
|
||||
value = editor.getValue();
|
||||
});
|
||||
|
||||
// Setup proxy YAML completion if enabled
|
||||
if (enableProxyCompletion && language === 'yaml') {
|
||||
try {
|
||||
proxyCompletionProvider = setupProxyYamlCompletion(monaco);
|
||||
} catch (error) {
|
||||
console.warn('Failed to setup proxy YAML completion:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
// properly cleanup vim mode first
|
||||
destroyVimMode();
|
||||
// cleanup completion provider
|
||||
if (proxyCompletionProvider) {
|
||||
proxyCompletionProvider.dispose();
|
||||
proxyCompletionProvider = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -118,38 +160,28 @@
|
||||
}
|
||||
|
||||
const initializeVimMode = () => {
|
||||
if (localVimMode && editor && !vimModeInstance) {
|
||||
import('monaco-vim')
|
||||
.then((vimModule) => {
|
||||
const statusNode = vimStatusBar;
|
||||
vimModeInstance = vimModule.initVimMode(editor, statusNode);
|
||||
if (localVimMode && editor && !vimModeInstance && !isDestroyed) {
|
||||
try {
|
||||
const statusNode = vimStatusBar;
|
||||
vimModeInstance = vimModule.initVimMode(editor, statusNode);
|
||||
|
||||
// integrate system clipboard with vim registers
|
||||
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn('vim mode not available - monaco-vim package not installed');
|
||||
});
|
||||
// integrate system clipboard with vim registers
|
||||
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
|
||||
} catch (e) {
|
||||
console.error('failed to start vim mode', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const destroyVimMode = () => {
|
||||
if (vimModeInstance) {
|
||||
try {
|
||||
// cleanup clipboard integration first
|
||||
destroyVimClipboardIntegration(vimModeInstance);
|
||||
|
||||
// use official monaco-vim dispose method
|
||||
vimModeInstance.dispose();
|
||||
} catch (e) {
|
||||
console.warn('Error disposing vim mode:', e);
|
||||
}
|
||||
|
||||
// clear vim status bar
|
||||
if (vimStatusBar) {
|
||||
vimStatusBar.textContent = '';
|
||||
}
|
||||
|
||||
vimModeInstance = null;
|
||||
}
|
||||
};
|
||||
@@ -161,22 +193,18 @@
|
||||
localVimMode = $vimModeEnabled;
|
||||
}
|
||||
|
||||
// debounce vim mode changes to prevent race conditions
|
||||
let vimModeTimeout = null;
|
||||
|
||||
// Watch for vim mode changes
|
||||
$: if (editor && typeof localVimMode === 'boolean') {
|
||||
if (vimModeTimeout) {
|
||||
clearTimeout(vimModeTimeout);
|
||||
$: if (editor && !isDestroyed && typeof localVimMode === 'boolean') {
|
||||
if (localVimMode && !vimModeInstance) {
|
||||
// Wait for DOM updates to complete
|
||||
tick().then(() => {
|
||||
if (localVimMode && !vimModeInstance && !isDestroyed) {
|
||||
initializeVimMode();
|
||||
}
|
||||
});
|
||||
} else if (!localVimMode && vimModeInstance) {
|
||||
destroyVimMode();
|
||||
}
|
||||
vimModeTimeout = setTimeout(() => {
|
||||
if (localVimMode && !vimModeInstance) {
|
||||
initializeVimMode();
|
||||
} else if (!localVimMode && vimModeInstance) {
|
||||
destroyVimMode();
|
||||
}
|
||||
vimModeTimeout = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
let showExample = false;
|
||||
@@ -192,46 +220,61 @@
|
||||
|
||||
<div class="w-full">
|
||||
<div class="bg-white dark:bg-gray-800 transition-colors duration-200 rounded-md">
|
||||
{#if showVimToggle}
|
||||
{#if showVimToggle || enableProxyCompletion}
|
||||
<div
|
||||
class="flex justify-between items-center p-2 border-b border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
vimModeEnabled.update((v) => !v);
|
||||
}}
|
||||
class="h-8 border-2 border-gray-300 dark:border-gray-600 rounded-md px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
class:font-bold={localVimMode}
|
||||
class:bg-cta-blue={localVimMode}
|
||||
class:dark:bg-indigo-600={localVimMode}
|
||||
class:text-white={localVimMode}
|
||||
>
|
||||
<span>Vim</span>
|
||||
</button>
|
||||
{#if showVimToggle}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
vimModeEnabled.update((v) => !v);
|
||||
}}
|
||||
class="h-8 border-2 rounded-md w-36 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
|
||||
class:font-bold={localVimMode}
|
||||
class:bg-blue-600={localVimMode}
|
||||
class:dark:bg-blue-500={localVimMode}
|
||||
class:text-white={localVimMode}
|
||||
class:border-blue-600={localVimMode}
|
||||
class:dark:border-blue-500={localVimMode}
|
||||
class:text-gray-700={!localVimMode}
|
||||
class:dark:text-gray-200={!localVimMode}
|
||||
class:bg-white={!localVimMode}
|
||||
class:dark:bg-gray-700={!localVimMode}
|
||||
class:border-gray-300={!localVimMode}
|
||||
class:dark:border-gray-600={!localVimMode}
|
||||
>
|
||||
<span>Vim</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if enableProxyCompletion}
|
||||
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Ctrl+Space for suggestions • Tab to accept</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={editorContainer}
|
||||
class="border-2 border-black dark:border-gray-600 bg-white dark:bg-gray-900 w-full transition-colors duration-200"
|
||||
class:rounded-b-md={showVimToggle}
|
||||
class:rounded-md={!showVimToggle}
|
||||
class:h-64={height === 'small' && !localVimMode}
|
||||
class:h-80={height === 'medium' && !localVimMode}
|
||||
class:h-96={height === 'large' && !localVimMode}
|
||||
style={localVimMode
|
||||
? `height: ${height === 'small' ? '224px' : height === 'medium' ? '294px' : '359px'}`
|
||||
: ''}
|
||||
></div>
|
||||
{#if localVimMode}
|
||||
<div class="border-2 border-gray-800 w-full rounded-lg overflow-hidden">
|
||||
<div
|
||||
bind:this={vimStatusBar}
|
||||
class="px-2 py-1 bg-gray-100 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300 rounded-b-md"
|
||||
style="height: 25px;"
|
||||
bind:this={editorContainer}
|
||||
class="w-full"
|
||||
class:h-64={height === 'small' && !localVimMode}
|
||||
class:h-80={height === 'medium' && !localVimMode}
|
||||
class:h-96={height === 'large' && !localVimMode}
|
||||
style={localVimMode
|
||||
? `height: ${height === 'small' ? '224px' : height === 'medium' ? '294px' : '359px'}`
|
||||
: ''}
|
||||
></div>
|
||||
{/if}
|
||||
{#if localVimMode}
|
||||
<div
|
||||
bind:this={vimStatusBar}
|
||||
class="px-2 py-1 bg-gray-700 text-xs font-mono text-gray-300"
|
||||
style="height: 25px;"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if placeholder}
|
||||
<div class="mt-2">
|
||||
@@ -244,25 +287,31 @@
|
||||
</button>
|
||||
{#if showExample}
|
||||
<div
|
||||
class="mt-2 p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md transition-colors duration-200"
|
||||
class="mt-2 p-3 bg-gray-900 dark:bg-black border border-gray-600 dark:border-gray-700 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span
|
||||
class="text-xs font-medium text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
class="text-xs font-medium text-gray-300 dark:text-gray-200 transition-colors duration-200"
|
||||
>Example:</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={loadExample}
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline transition-colors duration-200"
|
||||
class="text-xs text-blue-400 dark:text-blue-300 hover:text-blue-300 dark:hover:text-blue-200 underline transition-colors duration-200"
|
||||
>
|
||||
Load example
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap transition-colors duration-200 select-text cursor-text">{placeholder}</pre>
|
||||
class="text-xs text-gray-300 dark:text-gray-200 whitespace-pre-wrap transition-colors duration-200 select-text cursor-text">{placeholder}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.monaco-editor .current-line) {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* Monaco Editor YAML completion provider for proxy configurations
|
||||
*/
|
||||
|
||||
export class ProxyYamlCompletionProvider {
|
||||
constructor(monaco) {
|
||||
this.monaco = monaco;
|
||||
this.completionProvider = null;
|
||||
this.hoverProvider = null;
|
||||
this.setupLanguageFeatures();
|
||||
}
|
||||
|
||||
setupLanguageFeatures() {
|
||||
try {
|
||||
if (!this.completionProvider) {
|
||||
this.completionProvider = this.monaco.languages.registerCompletionItemProvider('yaml', {
|
||||
provideCompletionItems: (model, position) => {
|
||||
try {
|
||||
return this.provideCompletionItems(model, position);
|
||||
} catch (error) {
|
||||
console.warn('Completion error:', error);
|
||||
return { suggestions: [] };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.hoverProvider) {
|
||||
this.hoverProvider = this.monaco.languages.registerHoverProvider('yaml', {
|
||||
provideHover: (model, position) => {
|
||||
try {
|
||||
return this.provideHover(model, position);
|
||||
} catch (error) {
|
||||
console.warn('Hover error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to setup language features:', error);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.completionProvider) {
|
||||
this.completionProvider.dispose();
|
||||
this.completionProvider = null;
|
||||
}
|
||||
if (this.hoverProvider) {
|
||||
this.hoverProvider.dispose();
|
||||
this.hoverProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
provideCompletionItems(model, position) {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn
|
||||
};
|
||||
|
||||
const lineContent = model.getLineContent(position.lineNumber);
|
||||
const linePrefix = lineContent.substring(0, position.column - 1);
|
||||
const fullContent = model.getValue();
|
||||
const linesAbove = model.getLinesContent().slice(0, position.lineNumber - 1);
|
||||
|
||||
return {
|
||||
suggestions: this.getSuggestions(linePrefix, range, linesAbove, fullContent)
|
||||
};
|
||||
}
|
||||
|
||||
getSuggestions(linePrefix, range, linesAbove, fullContent) {
|
||||
const suggestions = [];
|
||||
const currentIndent = this.getIndent(linePrefix);
|
||||
|
||||
// Handle specific field value completions
|
||||
if (linePrefix.match(/mode:\s*$/)) {
|
||||
return this.getModeSuggestions(range);
|
||||
}
|
||||
if (linePrefix.match(/from:\s*$/)) {
|
||||
return this.getFromSuggestions(range);
|
||||
}
|
||||
if (linePrefix.match(/method:\s*$/)) {
|
||||
return this.getMethodSuggestions(range);
|
||||
}
|
||||
if (linePrefix.match(/(with_session|without_session):\s*$/)) {
|
||||
return this.getActionSuggestions(range);
|
||||
}
|
||||
|
||||
// Handle array items
|
||||
if (linePrefix.match(/^\s*-\s*$/)) {
|
||||
const context = this.findParentSection(linesAbove, currentIndent);
|
||||
if (context === 'paths') {
|
||||
return this.getPathPatternSuggestions(range);
|
||||
}
|
||||
if (context === 'capture') {
|
||||
return this.getNewCaptureSuggestions(range);
|
||||
}
|
||||
if (context === 'rewrite') {
|
||||
return this.getNewRewriteSuggestions(range);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle field completions based on context
|
||||
const context = this.findParentSection(linesAbove, currentIndent);
|
||||
|
||||
if (currentIndent === 0) {
|
||||
return this.getTopLevelSuggestions(range);
|
||||
}
|
||||
|
||||
switch (context) {
|
||||
case 'global':
|
||||
return this.getGlobalSuggestions(range);
|
||||
case 'domain':
|
||||
return this.getDomainSuggestions(range);
|
||||
case 'access':
|
||||
return this.getAccessSuggestions(range);
|
||||
case 'on_deny':
|
||||
return this.getOnDenySuggestions(range);
|
||||
case 'capture':
|
||||
return this.getCaptureSuggestions(range);
|
||||
case 'rewrite':
|
||||
return this.getRewriteSuggestions(range);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getIndent(line) {
|
||||
const match = line.match(/^\s*/);
|
||||
return match ? match[0].length : 0;
|
||||
}
|
||||
|
||||
findParentSection(linesAbove, currentIndent) {
|
||||
// Look backwards to find the parent section
|
||||
for (let i = linesAbove.length - 1; i >= 0; i--) {
|
||||
const line = linesAbove[i];
|
||||
const lineIndent = this.getIndent(line);
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// If we find a line with less indentation that has a colon, it's a parent
|
||||
if (lineIndent < currentIndent && trimmed.includes(':')) {
|
||||
const key = trimmed.split(':')[0].trim();
|
||||
|
||||
// Top level sections
|
||||
if (lineIndent === 0) {
|
||||
if (key === 'global') return 'global';
|
||||
if (key.match(/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) return 'domain';
|
||||
}
|
||||
|
||||
// Nested sections
|
||||
if (key === 'access') return 'access';
|
||||
if (key === 'capture') return 'capture';
|
||||
if (key === 'rewrite') return 'rewrite';
|
||||
if (key === 'on_deny') return 'on_deny';
|
||||
if (key === 'paths') return 'paths';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getTopLevelSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'version',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'version: "0.0"',
|
||||
documentation: 'Configuration version',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'proxy',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'proxy: "proxy-name"',
|
||||
documentation: 'Optional proxy name',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'global',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'global:',
|
||||
documentation: 'Global rules for all domains',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getGlobalSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'access',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'access:',
|
||||
documentation: 'Global access control',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'capture',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'capture:',
|
||||
documentation: 'Global capture rules',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'rewrite',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'rewrite:',
|
||||
documentation: 'Global rewrite rules',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getDomainSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'to',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'to: "phishing-domain.com"',
|
||||
documentation: 'Target phishing domain (required)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'access',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'access:',
|
||||
documentation: 'Domain access control',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'capture',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'capture:',
|
||||
documentation: 'Domain capture rules',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'rewrite',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'rewrite:',
|
||||
documentation: 'Domain rewrite rules',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getAccessSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'mode',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'mode: "allow"',
|
||||
documentation: 'Access control mode: allow or deny',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'paths',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'paths:',
|
||||
documentation: 'Array of path patterns',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'on_deny',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'on_deny:',
|
||||
documentation: 'Response when access denied',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getOnDenySuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'with_session',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'with_session: 403',
|
||||
documentation: 'Response for users with sessions',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'without_session',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'without_session: 404',
|
||||
documentation: 'Response for users without sessions',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getCaptureSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'name',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'name: "capture_name"',
|
||||
documentation: 'Unique capture rule name (required)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'method',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'method: "POST"',
|
||||
documentation: 'HTTP method to match',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'path',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'path: "/login"',
|
||||
documentation: 'URL path pattern to match (required)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'find',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'find: "pattern"',
|
||||
documentation: 'Regex pattern to capture',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'from',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'from: "request_body"',
|
||||
documentation: 'Where to search for pattern',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'required',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'required: true',
|
||||
documentation: 'Whether capture is required',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getRewriteSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'name',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'name: "rewrite_name"',
|
||||
documentation: 'Optional rewrite rule name',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'find',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'find: "pattern"',
|
||||
documentation: 'Regex pattern to find (required)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'replace',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'replace: "replacement"',
|
||||
documentation: 'Replacement text (required)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'from',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'from: "response_body"',
|
||||
documentation: 'Where to apply replacement',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getNewCaptureSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'capture rule',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "capture_name"\n method: "POST"\n path: "/path"\n find: "pattern"\n from: "request_body"',
|
||||
documentation: 'New capture rule template',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getNewRewriteSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'rewrite rule',
|
||||
kind: this.monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
'name: "rewrite_name"\n find: "pattern"\n replace: "replacement"\n from: "response_body"',
|
||||
documentation: 'New rewrite rule template',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getModeSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"allow"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"allow"',
|
||||
documentation: 'Allowlist mode - only specified paths allowed',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"deny"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"deny"',
|
||||
documentation: 'Denylist mode - specified paths blocked',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFromSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"request_body"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"request_body"',
|
||||
documentation: 'Search in request body',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"request_header"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"request_header"',
|
||||
documentation: 'Search in request headers',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"response_body"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"response_body"',
|
||||
documentation: 'Search in response body',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"response_header"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"response_header"',
|
||||
documentation: 'Search in response headers',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"cookie"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"cookie"',
|
||||
documentation: 'Capture cookie data',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"any"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"any"',
|
||||
documentation: 'Search anywhere',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getMethodSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"GET"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"GET"',
|
||||
documentation: 'HTTP GET method',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"POST"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"POST"',
|
||||
documentation: 'HTTP POST method',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"PUT"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"PUT"',
|
||||
documentation: 'HTTP PUT method',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"DELETE"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"DELETE"',
|
||||
documentation: 'HTTP DELETE method',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getActionSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"allow"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"allow"',
|
||||
documentation: 'Allow access (override deny)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"redirect:https://example.com"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"redirect:https://example.com"',
|
||||
documentation: 'Redirect to URL',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '404',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '404',
|
||||
documentation: 'Return 404 Not Found',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '403',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '403',
|
||||
documentation: 'Return 403 Forbidden',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '503',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '503',
|
||||
documentation: 'Return 503 Service Unavailable',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getPathPatternSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"^/admin/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/admin/"',
|
||||
documentation: 'Admin panel paths',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/login"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/login"',
|
||||
documentation: 'Login page',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/api/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/api/"',
|
||||
documentation: 'API endpoints',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/assets/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/assets/"',
|
||||
documentation: 'Static assets',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/\\.git/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/\\.git/"',
|
||||
documentation: 'Git repository',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
provideHover(model, position) {
|
||||
const word = model.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
|
||||
const hoverInfo = this.getHoverInfo(word.word);
|
||||
if (!hoverInfo) return null;
|
||||
|
||||
return {
|
||||
range: new this.monaco.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
),
|
||||
contents: [{ value: `**${word.word}**` }, { value: hoverInfo }]
|
||||
};
|
||||
}
|
||||
|
||||
getHoverInfo(word) {
|
||||
const hoverData = {
|
||||
version: 'Configuration version. Currently supports "0.0"',
|
||||
global: 'Rules that apply to all domain mappings',
|
||||
access: 'Access control configuration - restricts which paths are accessible',
|
||||
mode: 'Access control mode: "allow" (allowlist) or "deny" (denylist)',
|
||||
paths: 'Array of regex patterns for path matching',
|
||||
on_deny: 'Response configuration when access is denied',
|
||||
with_session: 'Response for users with active proxy sessions (request with mitm cookie)',
|
||||
without_session: 'Response for requests without sessions',
|
||||
capture: 'Rules for capturing data from requests/responses',
|
||||
name: 'Unique identifier for the rule',
|
||||
method: 'HTTP method to match (GET, POST, PUT, DELETE, etc.)',
|
||||
path: 'URL path pattern to match (regex)',
|
||||
find: 'Regex pattern to capture data, or cookie name if from=cookie',
|
||||
from: 'Location to search: request_body, request_header, response_body, response_header, cookie, any',
|
||||
required: 'Whether this capture is required for page and capture completion',
|
||||
rewrite: 'Rules for modifying request/response content',
|
||||
replace: 'Replacement text for the find pattern',
|
||||
to: 'Target phishing domain for this original domain'
|
||||
};
|
||||
|
||||
return hoverData[word] || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Global provider instance to prevent duplicates
|
||||
let globalProvider = null;
|
||||
|
||||
// Initialize the completion provider
|
||||
export function setupProxyYamlCompletion(monaco) {
|
||||
// Dispose existing provider if it exists
|
||||
if (globalProvider) {
|
||||
globalProvider.dispose();
|
||||
}
|
||||
|
||||
// Create new provider
|
||||
globalProvider = new ProxyYamlCompletionProvider(monaco);
|
||||
return globalProvider;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* hacky vim copy to clipboard on visual mode d or y key down
|
||||
* supports yy and dd and thats it.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,39 +62,18 @@
|
||||
name: null
|
||||
};
|
||||
|
||||
const currentExample = `version: "0.0" # config version
|
||||
proxy: 172.20.0.138:8081 # proxy server address
|
||||
global: # rules applied to all domains
|
||||
rewrite:
|
||||
- name: loose rename integrity # required identifier
|
||||
find: integrity=
|
||||
replace: data-no-integrity=
|
||||
from: response_body # where to apply
|
||||
login.example.com: # original domain
|
||||
to: login.phishingclub.test # proxy domain
|
||||
capture:
|
||||
- name: username # required identifier
|
||||
method: POST # http method
|
||||
path: /auth # url path pattern
|
||||
find: username=([^&]+) # regex pattern to capture
|
||||
from: request_body # where to search: request_body|request_header|response_body|response_header|cookie|any
|
||||
- name: password # required identifier
|
||||
method: POST # http method
|
||||
path: /auth # url path pattern
|
||||
find: password=([^&]+) # regex pattern to capture
|
||||
from: request_body # where to search
|
||||
- name: session_token # required identifier
|
||||
method: GET # http method
|
||||
path: /dashboard # url path pattern
|
||||
find: SESSIONID # cookie name to capture
|
||||
from: cookie # captures full cookie data
|
||||
rewrite:
|
||||
- name: hide_warning # required identifier
|
||||
find: security-warning # text/pattern to find
|
||||
replace: hidden # replacement text
|
||||
from: response_body # where to apply: request_body|request_header|response_body|response_header|any
|
||||
www.example.com: # original domain
|
||||
to: www.phishingclub.test # proxy domain`;
|
||||
const currentExample = `version: "0.0"
|
||||
proxy: "My Proxy Campaign"
|
||||
|
||||
portal.example.com:
|
||||
to: "evil.example.com"
|
||||
capture:
|
||||
- name: "credentials"
|
||||
method: "POST"
|
||||
path: "/login"
|
||||
find: "username=([^&]+).*password=([^&]+)"
|
||||
from: "request_body"
|
||||
required: true`;
|
||||
|
||||
$: {
|
||||
modalText = getModalText('Proxy', modalMode);
|
||||
@@ -442,6 +421,7 @@ www.example.com: # original domain
|
||||
height="large"
|
||||
language="yaml"
|
||||
placeholder={currentExample}
|
||||
enableProxyCompletion={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user