├── .dockerignore
├── .env.template
├── .gitattributes
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── contributing.md
└── workflows
│ ├── portr-admin.yml
│ └── release.yml
├── .gitignore
├── .goreleaser.yaml
├── .pre-commit-config.yaml
├── .well-known
└── funding-manifest-urls
├── LICENSE
├── Makefile
├── README.md
├── admin
├── .dockerignore
├── .env.template
├── .gitignore
├── .python-version
├── Dockerfile
├── Makefile
├── README.md
├── apis
│ ├── __init__.py
│ ├── pagination.py
│ ├── security.py
│ └── v1
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── config.py
│ │ ├── connection.py
│ │ ├── instance_settings.py
│ │ ├── team.py
│ │ └── user.py
├── config
│ ├── __init__.py
│ ├── beats.py
│ ├── database.py
│ ├── enums.py
│ └── settings.py
├── conftest.py
├── main.py
├── migrations
│ └── models
│ │ ├── 0_20240326150919_init.py.py
│ │ └── 1_20240813141115_add-password-to-user.py
├── models
│ ├── __init__.py
│ ├── auth.py
│ ├── connection.py
│ ├── settings.py
│ └── user.py
├── pyproject.toml
├── requirements-dev.lock
├── requirements.lock
├── schemas
│ ├── __init__.py
│ ├── auth.py
│ ├── connection.py
│ ├── settings.py
│ ├── team.py
│ └── user.py
├── scripts
│ ├── generate-encryption-key.sh
│ ├── pre-deploy.py
│ └── start.sh
├── services
│ ├── __init__.py
│ ├── auth.py
│ ├── connection.py
│ ├── settings.py
│ ├── team.py
│ └── user.py
├── static
│ ├── Geist-Regular.woff2
│ ├── Manrope-VariableFont_wght.ttf
│ ├── favicon.ico
│ ├── favicon.svg
│ ├── geist-mono-latin-400-normal.woff2
│ └── icon.svg
├── templates
│ └── index.html
├── tests
│ ├── __init__.py
│ ├── api_tests
│ │ ├── __init__.py
│ │ ├── test_auth.py
│ │ ├── test_config.py
│ │ ├── test_connection.py
│ │ ├── test_instance_settings.py
│ │ ├── test_team.py
│ │ └── test_user.py
│ ├── factories.py
│ ├── service_tests
│ │ ├── __init__.py
│ │ ├── test_connection_service.py
│ │ └── test_user_service.py
│ ├── test_pages.py
│ └── test_utils.py
├── utils
│ ├── __init__.py
│ ├── exception.py
│ ├── github_auth.py
│ ├── smtp.py
│ ├── template_renderer.py
│ ├── token.py
│ └── vite.py
└── web
│ ├── .gitignore
│ ├── components.json
│ ├── index.html
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── postcss.config.cjs
│ ├── src
│ ├── App.svelte
│ ├── app.pcss
│ ├── lib
│ │ ├── components
│ │ │ ├── ApiError.svelte
│ │ │ ├── ConnectionStatus.svelte
│ │ │ ├── ConnectionType.svelte
│ │ │ ├── DateField.svelte
│ │ │ ├── ErrorText.svelte
│ │ │ ├── Pagination.svelte
│ │ │ ├── copyToClipboard.svelte
│ │ │ ├── data-table-skeleton.svelte
│ │ │ ├── data-table.svelte
│ │ │ ├── error.svelte
│ │ │ ├── goback.svelte
│ │ │ ├── issue-link.svelte
│ │ │ ├── new-team-dialog.svelte
│ │ │ ├── sidebarlink.svelte
│ │ │ ├── team-selector.svelte
│ │ │ ├── ui
│ │ │ │ ├── alert-dialog
│ │ │ │ │ ├── alert-dialog-action.svelte
│ │ │ │ │ ├── alert-dialog-cancel.svelte
│ │ │ │ │ ├── alert-dialog-content.svelte
│ │ │ │ │ ├── alert-dialog-description.svelte
│ │ │ │ │ ├── alert-dialog-footer.svelte
│ │ │ │ │ ├── alert-dialog-header.svelte
│ │ │ │ │ ├── alert-dialog-overlay.svelte
│ │ │ │ │ ├── alert-dialog-portal.svelte
│ │ │ │ │ ├── alert-dialog-title.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── alert
│ │ │ │ │ ├── alert-description.svelte
│ │ │ │ │ ├── alert-title.svelte
│ │ │ │ │ ├── alert.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── avatar
│ │ │ │ │ ├── avatar-fallback.svelte
│ │ │ │ │ ├── avatar-image.svelte
│ │ │ │ │ ├── avatar.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── badge
│ │ │ │ │ ├── badge.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── button
│ │ │ │ │ ├── button.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── card
│ │ │ │ │ ├── card-content.svelte
│ │ │ │ │ ├── card-description.svelte
│ │ │ │ │ ├── card-footer.svelte
│ │ │ │ │ ├── card-header.svelte
│ │ │ │ │ ├── card-title.svelte
│ │ │ │ │ ├── card.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── checkbox
│ │ │ │ │ ├── checkbox.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── dialog
│ │ │ │ │ ├── dialog-content.svelte
│ │ │ │ │ ├── dialog-description.svelte
│ │ │ │ │ ├── dialog-footer.svelte
│ │ │ │ │ ├── dialog-header.svelte
│ │ │ │ │ ├── dialog-overlay.svelte
│ │ │ │ │ ├── dialog-portal.svelte
│ │ │ │ │ ├── dialog-title.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── dropdown-menu
│ │ │ │ │ ├── dropdown-menu-checkbox-item.svelte
│ │ │ │ │ ├── dropdown-menu-content.svelte
│ │ │ │ │ ├── dropdown-menu-item.svelte
│ │ │ │ │ ├── dropdown-menu-label.svelte
│ │ │ │ │ ├── dropdown-menu-radio-group.svelte
│ │ │ │ │ ├── dropdown-menu-radio-item.svelte
│ │ │ │ │ ├── dropdown-menu-separator.svelte
│ │ │ │ │ ├── dropdown-menu-shortcut.svelte
│ │ │ │ │ ├── dropdown-menu-sub-content.svelte
│ │ │ │ │ ├── dropdown-menu-sub-trigger.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── input
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── input.svelte
│ │ │ │ ├── label
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── label.svelte
│ │ │ │ ├── pagination
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── pagination-content.svelte
│ │ │ │ │ ├── pagination-ellipsis.svelte
│ │ │ │ │ ├── pagination-item.svelte
│ │ │ │ │ ├── pagination-link.svelte
│ │ │ │ │ ├── pagination-next-button.svelte
│ │ │ │ │ ├── pagination-prev-button.svelte
│ │ │ │ │ └── pagination.svelte
│ │ │ │ ├── select
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── select-content.svelte
│ │ │ │ │ ├── select-item.svelte
│ │ │ │ │ ├── select-label.svelte
│ │ │ │ │ ├── select-separator.svelte
│ │ │ │ │ └── select-trigger.svelte
│ │ │ │ ├── separator
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── separator.svelte
│ │ │ │ ├── skeleton
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── skeleton.svelte
│ │ │ │ ├── switch
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── switch.svelte
│ │ │ │ ├── table
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── table-body.svelte
│ │ │ │ │ ├── table-caption.svelte
│ │ │ │ │ ├── table-cell.svelte
│ │ │ │ │ ├── table-footer.svelte
│ │ │ │ │ ├── table-head.svelte
│ │ │ │ │ ├── table-header.svelte
│ │ │ │ │ ├── table-row.svelte
│ │ │ │ │ └── table.svelte
│ │ │ │ ├── textarea
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── textarea.svelte
│ │ │ │ └── tooltip
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── tooltip-content.svelte
│ │ │ └── users
│ │ │ │ ├── avatar.svelte
│ │ │ │ ├── delete.svelte
│ │ │ │ ├── invite-user.svelte
│ │ │ │ ├── members.svelte
│ │ │ │ └── user-email.svelte
│ │ ├── humanize.ts
│ │ ├── services
│ │ │ └── user.ts
│ │ ├── store.ts
│ │ ├── types.d.ts
│ │ └── utils.ts
│ ├── main.ts
│ ├── pages
│ │ ├── app-layout.svelte
│ │ ├── app
│ │ │ ├── app.svelte
│ │ │ ├── connections.svelte
│ │ │ ├── email-settings.svelte
│ │ │ ├── myaccount.svelte
│ │ │ ├── overview.svelte
│ │ │ └── users.svelte
│ │ ├── home.svelte
│ │ ├── instance-settings
│ │ │ ├── emailSettings.svelte
│ │ │ ├── index.svelte
│ │ │ ├── newteam.svelte
│ │ │ └── page.svelte
│ │ └── notfound.svelte
│ └── vite-env.d.ts
│ ├── svelte.config.js
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── docker-compose.dev.yaml
├── docker-compose.yaml
├── docs
├── .gitignore
├── .nvmrc
├── .vscode
│ ├── extensions.json
│ └── launch.json
├── astro.config.mjs
├── package.json
├── pnpm-lock.yaml
├── public
│ ├── favicon.ico
│ ├── funding.json
│ ├── icon.svg
│ └── og.png
├── src
│ ├── assets
│ │ ├── cloudflare.png
│ │ ├── icon.png
│ │ └── icon.svg
│ ├── components
│ │ └── Head.astro
│ ├── content
│ │ ├── config.ts
│ │ └── docs
│ │ │ ├── client
│ │ │ ├── http-tunnel.md
│ │ │ ├── installation.mdx
│ │ │ ├── tcp-tunnel.md
│ │ │ ├── templates.md
│ │ │ └── websocket-tunnel.md
│ │ │ ├── getting-started.mdx
│ │ │ ├── index.mdx
│ │ │ ├── local-development
│ │ │ ├── admin.md
│ │ │ ├── portr-client.md
│ │ │ └── tunnel-server.md
│ │ │ ├── resources
│ │ │ └── route53.mdx
│ │ │ └── server
│ │ │ ├── cloudflare-api-token.mdx
│ │ │ ├── github-oauth-app.mdx
│ │ │ ├── index.md
│ │ │ └── start-the-tunnel-server.mdx
│ ├── env.d.ts
│ ├── fonts
│ │ ├── Geist-Regular.woff2
│ │ └── font-face.css
│ ├── styles
│ │ └── custom.css
│ └── tailwind.css
├── tailwind.config.mjs
└── tsconfig.json
├── e2e
└── setup_test_data.py
├── install.sh
└── tunnel
├── .air.toml
├── .dockerignore
├── .env.template
├── .gitignore
├── Dockerfile
├── Makefile
├── client.dev.yaml
├── cmd
├── portr
│ ├── auth.go
│ ├── config.go
│ ├── http.go
│ ├── main.go
│ ├── start.go
│ ├── tcp.go
│ └── updates.go
└── portrd
│ └── main.go
├── go.mod
├── go.sum
└── internal
├── client
├── client
│ └── client.go
├── config
│ └── config.go
├── dashboard
│ ├── dashboard.go
│ ├── handler
│ │ ├── handler.go
│ │ └── tunnels.go
│ ├── service
│ │ ├── service.go
│ │ └── tunnels.go
│ ├── templates
│ │ ├── index.html
│ │ └── templates.go
│ └── ui
│ │ ├── .gitignore
│ │ ├── .vscode
│ │ └── extensions.json
│ │ ├── components.json
│ │ ├── dist
│ │ └── dist.go
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── pnpm-lock.yaml
│ │ ├── postcss.config.cjs
│ │ ├── public
│ │ ├── Geist-Regular.woff2
│ │ └── favicon.svg
│ │ ├── src
│ │ ├── App.svelte
│ │ ├── app.pcss
│ │ ├── assets
│ │ │ └── svelte.svg
│ │ ├── lib
│ │ │ ├── components
│ │ │ │ ├── HttpBadge.svelte
│ │ │ │ ├── InspectorIcon.svelte
│ │ │ │ ├── RenderContent.svelte
│ │ │ │ ├── RenderFormUrlEncoded.svelte
│ │ │ │ ├── RenderMultipartFormData.svelte
│ │ │ │ └── ui
│ │ │ │ │ ├── button
│ │ │ │ │ ├── button.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ │ ├── card
│ │ │ │ │ ├── card-content.svelte
│ │ │ │ │ ├── card-description.svelte
│ │ │ │ │ ├── card-footer.svelte
│ │ │ │ │ ├── card-header.svelte
│ │ │ │ │ ├── card-title.svelte
│ │ │ │ │ ├── card.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ │ ├── input
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── input.svelte
│ │ │ │ │ ├── label
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── label.svelte
│ │ │ │ │ ├── table
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── table-body.svelte
│ │ │ │ │ ├── table-caption.svelte
│ │ │ │ │ ├── table-cell.svelte
│ │ │ │ │ ├── table-footer.svelte
│ │ │ │ │ ├── table-head.svelte
│ │ │ │ │ ├── table-header.svelte
│ │ │ │ │ ├── table-row.svelte
│ │ │ │ │ └── table.svelte
│ │ │ │ │ └── tabs
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── tabs-content.svelte
│ │ │ │ │ ├── tabs-list.svelte
│ │ │ │ │ ├── tabs-trigger.svelte
│ │ │ │ │ └── tabs.svelte
│ │ │ ├── store.ts
│ │ │ ├── types.d.ts
│ │ │ └── utils.ts
│ │ ├── main.ts
│ │ ├── pages
│ │ │ ├── Home.svelte
│ │ │ ├── Inspect.svelte
│ │ │ └── RequestDetails.svelte
│ │ └── vite-env.d.ts
│ │ ├── svelte.config.js
│ │ ├── tailwind.config.js
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
├── db
│ └── db.go
├── ssh
│ └── ssh.go
├── tui
│ └── tui.go
└── vite
│ └── vite.go
├── constants
└── constants.go
├── server
├── config
│ └── config.go
├── cron
│ ├── cron.go
│ ├── ping.go
│ └── tasks.go
├── db
│ ├── db.go
│ └── models.go
├── proxy
│ └── proxy.go
├── service
│ └── service.go
└── ssh
│ └── sshd.go
└── utils
├── dir.go
├── dir_test.go
├── error-templates
├── connection-lost.html
├── local-server-not-online.html
└── unregistered-subdomain.html
├── error.go
├── http.go
├── id.go
├── port.go
├── random.go
├── request.go
├── string.go
├── subdomain.go
└── subdomain_test.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/node_modules
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | PORTR_ADMIN_GITHUB_CLIENT_ID=
2 | PORTR_ADMIN_GITHUB_CLIENT_SECRET=
3 |
4 | PORTR_DOMAIN=example.com
5 | PORTR_DB_URL=postgres://postgres:postgres@localhost:5432/postgres
6 |
7 | PORTR_SERVER_URL=example.com
8 | PORTR_SSH_URL=example.com:2222
9 |
10 | CLOUDFLARE_API_TOKEN=
11 |
12 | POSTGRES_USER=postgres
13 | POSTGRES_PASSWORD=postgres
14 | POSTGRES_DB=postgres
15 |
16 | PORTR_ADMIN_ENCRYPTION_KEY=
17 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | **/*.svelte -linguist-detectable
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [amalshaji]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: amalshaji
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Additional context**
27 | Add any other context about the problem here.
28 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/portr-admin.yml:
--------------------------------------------------------------------------------
1 | name: Portr admin
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | paths:
7 | - "admin/**"
8 | pull_request:
9 | branches: ["main"]
10 | paths:
11 | - "admin/**"
12 |
13 | permissions:
14 | contents: read
15 |
16 | env:
17 | PORTR_DOMAIN: localhost:8000
18 | PORTR_ADMIN_GITHUB_CLIENT_ID: ""
19 | PORTR_ADMIN_GITHUB_CLIENT_SECRET: ""
20 | PORTR_SERVER_URL: localhost:8000
21 | PORTR_SSH_URL: localhost:2222
22 | PORTR_ADMIN_USE_VITE: true
23 | PORTR_ADMIN_ENCRYPTION_KEY: "mj-qoeMhLQp_cHnMU9nsLfCMnNkZ6XBcFefy4VxzOe8="
24 | PORTR_DB_URL: ""
25 |
26 | jobs:
27 | test:
28 | runs-on: ubuntu-latest
29 | defaults:
30 | run:
31 | working-directory: ./admin
32 | steps:
33 | - uses: actions/checkout@v3
34 | - name: Set up Python 3.12
35 | uses: actions/setup-python@v3
36 | with:
37 | python-version: "3.12"
38 | cache: "pip"
39 | cache-dependency-path: admin/requirements*.lock
40 | - name: Install dependencies
41 | run: |
42 | python -m pip install --upgrade pip
43 | sed '/-e/d' requirements.lock > requirements.txt && python -m pip install -r requirements.txt
44 | sed '/-e/d' requirements-dev.lock > requirements-dev.txt && python -m pip install -r requirements-dev.txt
45 | - name: Run tests
46 | run: |
47 | pytest
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | keys
2 | .DS_Store
3 | db.sqlite
4 | .env
5 | *.fiber.gz
6 | tmp
7 | portr
8 | !**/portr/
9 | postgres-data
10 | node_modules
11 | .mypy_cache
12 | data/
13 | dist
14 | !tunnel/internal/client/dashboard/dist
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | builds:
2 | - id: "portr"
3 | main: ./cmd/portr
4 | binary: "portr"
5 | dir: tunnel
6 | env:
7 | - CGO_ENABLED=0
8 | goos:
9 | - linux
10 | - windows
11 | - darwin
12 | goarch:
13 | - amd64
14 | - arm64
15 | ldflags:
16 | - -s -w -X main.version={{.Version}}
17 |
18 | archives:
19 | - format: zip
20 | name_template: >-
21 | {{ .Binary }}_
22 | {{- .Version }}_
23 | {{- title .Os }}_
24 | {{- if eq .Arch "amd64" }}x86_64
25 | {{- else if eq .Arch "386" }}i386
26 | {{- else }}{{ .Arch }}{{ end }}
27 | {{- if .Arm }}v{{ .Arm }}{{ end }}
28 | id: portr
29 |
30 | checksum:
31 | name_template: "checksums.txt"
32 |
33 | snapshot:
34 | name_template: "{{ incpatch .Version }}-next"
35 |
36 | release:
37 | name_template: "v{{ .Version }}"
38 | draft: true
39 |
40 | brews:
41 | - name: portr
42 | ids:
43 | - portr
44 | homepage: https://github.com/amalshaji/portr
45 | repository:
46 | owner: amalshaji
47 | name: homebrew-taps
48 | token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
49 | commit_author:
50 | name: Amal Shaji
51 | email: amalshajid@gmail.com
52 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/charliermarsh/ruff-pre-commit
3 | rev: "v0.2.2"
4 | hooks:
5 | - id: ruff
6 | args: [--fix, --exit-non-zero-on-fix]
7 | exclude: "(^.*/migrations/|^client/)"
8 | - id: ruff-format
9 | exclude: "(^.*/migrations/|^client/)"
10 |
11 | - repo: https://github.com/pre-commit/pre-commit-hooks
12 | rev: v4.5.0
13 | hooks:
14 | - id: trailing-whitespace
15 | exclude: "(^.*/migrations/|^client/)"
16 | - id: check-merge-conflict
17 | - id: debug-statements
18 | - id: check-added-large-files
19 |
20 | - repo: https://github.com/pre-commit/mirrors-mypy
21 | rev: v1.8.0
22 | hooks:
23 | - id: mypy
24 | args:
25 | - --follow-imports=skip
26 | - --ignore-missing-imports
27 | - --show-column-numbers
28 | - --no-pretty
29 | - --check-untyped-defs
30 | exclude: '(^.*/migrations/|^client/|_tests\.py$)'
31 |
--------------------------------------------------------------------------------
/.well-known/funding-manifest-urls:
--------------------------------------------------------------------------------
1 | https://portr.dev/funding.json
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | load-env:
2 | export $(cat .env | grep -v '^#' | xargs)
3 |
--------------------------------------------------------------------------------
/admin/.dockerignore:
--------------------------------------------------------------------------------
1 | .mypy_cache
2 | .pytest_cache
3 | .venv
4 | tmp
5 | .env
6 | **/__pycache__
7 | **/node_modules
8 |
--------------------------------------------------------------------------------
/admin/.env.template:
--------------------------------------------------------------------------------
1 | PORTR_DB_URL="postgres://postgres:postgres@localhost:5432/postgres"
2 | PORTR_DOMAIN=localhost:8000
3 |
4 | PORTR_ADMIN_DEBUG=True
5 | PORTR_ADMIN_USE_VITE=True
6 | PORTR_ADMIN_ENCRYPTION_KEY=
7 | PORTR_ADMIN_GITHUB_CLIENT_ID=
8 | PORTR_ADMIN_GITHUB_CLIENT_SECRET=
9 |
10 | PORTR_SERVER_URL=localhost:8000
11 | PORTR_SSH_URL=localhost:2222
12 |
--------------------------------------------------------------------------------
/admin/.gitignore:
--------------------------------------------------------------------------------
1 | .venv
2 | .mypy_cache
3 | .pytest_cache
4 | __pycache__
5 |
--------------------------------------------------------------------------------
/admin/.python-version:
--------------------------------------------------------------------------------
1 | 3.12.1
2 |
--------------------------------------------------------------------------------
/admin/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-slim AS frontend-builder
2 |
3 | WORKDIR /app
4 |
5 | COPY web/package.json web/pnpm-lock.yaml ./
6 |
7 | RUN npm i -g pnpm@9.7.0 && pnpm install --frozen-lockfile
8 |
9 | COPY web .
10 |
11 | RUN pnpm build
12 |
13 | FROM python:3.12 AS builder
14 |
15 | ENV PATH="/app/.venv/bin:$PATH"
16 |
17 | WORKDIR /app
18 |
19 | COPY pyproject.toml requirements.lock README.md ./
20 |
21 | RUN pip install --no-cache-dir uv==0.5.2
22 |
23 | RUN uv venv && \
24 | uv pip install --no-cache -r requirements.lock
25 |
26 | FROM python:3.12-slim AS final
27 |
28 | LABEL maintainer="Amal Shaji" \
29 | org.opencontainers.image.title="Portr Admin" \
30 | org.opencontainers.image.description="Admin server for Portr" \
31 | org.opencontainers.image.source="https://github.com/amalshaji/portr"
32 |
33 | ENV PATH="/app/.venv/bin:$PATH" \
34 | PYTHONPATH="/app/:$PYTHONPATH"
35 |
36 | RUN apt-get update && apt-get install -y --no-install-recommends \
37 | curl \
38 | && rm -rf /var/lib/apt/lists/*
39 |
40 | WORKDIR /app
41 |
42 | COPY --from=builder /app/.venv/ /app/.venv/
43 | COPY --from=frontend-builder /app/dist /app/web/dist
44 | COPY . .
45 |
46 | ENTRYPOINT ["sh", "scripts/start.sh"]
47 |
--------------------------------------------------------------------------------
/admin/Makefile:
--------------------------------------------------------------------------------
1 | init-migrations:
2 | aerich init -t db.TORTOISE_ORM
3 |
4 | init-db:
5 | aerich init-db
6 |
7 | create-migrations:
8 | aerich migrate --name $(name)
9 |
10 | run-migrations:
11 | aerich upgrade
12 |
13 | runserver:
14 | make run-migrations
15 | uvicorn main:app --reload
16 |
17 | installclient:
18 | pnpm --dir web install
19 |
20 | runclient:
21 | pnpm --dir web dev
--------------------------------------------------------------------------------
/admin/README.md:
--------------------------------------------------------------------------------
1 | # Portr admin
2 |
3 | Admin dashboard for portr.
4 |
--------------------------------------------------------------------------------
/admin/apis/__init__.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 | from apis.v1 import api as api_v1
3 |
4 | api = APIRouter(prefix="/api")
5 | api.include_router(api_v1)
6 |
--------------------------------------------------------------------------------
/admin/apis/pagination.py:
--------------------------------------------------------------------------------
1 | from typing import Generic, TypeVar
2 | from pydantic import BaseModel, ConfigDict
3 | from tortoise.queryset import QuerySet
4 | from tortoise.models import Model
5 |
6 | T = TypeVar("T")
7 | Qs_T = TypeVar("Qs_T", bound=Model)
8 |
9 |
10 | class PaginatedResponse(BaseModel, Generic[T]):
11 | count: int
12 | data: list[T]
13 |
14 | model_config = ConfigDict(arbitrary_types_allowed=True)
15 |
16 | @classmethod
17 | async def generate_response_for_page(
18 | self,
19 | qs: QuerySet[Qs_T],
20 | page: int,
21 | page_size: int = 10,
22 | ):
23 | if page < 1:
24 | page = 1
25 |
26 | self.count = await qs.count()
27 | self.data = await qs.limit(page_size).offset((page - 1) * page_size)
28 | return PaginatedResponse[T](count=self.count, data=self.data) # type: ignore
29 |
--------------------------------------------------------------------------------
/admin/apis/security.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 | from fastapi import Cookie, Depends, Header
3 |
4 | from models.auth import Session
5 | from models.user import Role, TeamUser, User
6 |
7 | from utils.exception import PermissionDenied
8 |
9 |
10 | class NotAuthenticated(Exception):
11 | pass
12 |
13 |
14 | async def get_current_user(
15 | portr_session: Annotated[str | None, Cookie()] = None,
16 | ) -> User:
17 | if portr_session is None:
18 | raise NotAuthenticated
19 |
20 | session = await Session.filter(token=portr_session).select_related("user").first()
21 | if session is None:
22 | raise NotAuthenticated
23 |
24 | return session.user
25 |
26 |
27 | async def get_current_team_user(
28 | user: User = Depends(get_current_user),
29 | x_team_slug: str | None = Header(),
30 | ) -> TeamUser:
31 | if x_team_slug is None:
32 | raise NotAuthenticated
33 |
34 | team_user = (
35 | await TeamUser.filter(user=user, team__slug=x_team_slug)
36 | .select_related("team", "user", "user__github_user")
37 | .first()
38 | )
39 | if team_user is None:
40 | raise NotAuthenticated
41 |
42 | return team_user
43 |
44 |
45 | async def requires_superuser(user: User = Depends(get_current_user)) -> User:
46 | if not user.is_superuser:
47 | raise PermissionDenied("Only superuser can perform this action")
48 |
49 | return user
50 |
51 |
52 | async def requires_admin(
53 | team_user: TeamUser = Depends(get_current_team_user),
54 | ) -> TeamUser:
55 | if team_user.role != Role.admin:
56 | raise PermissionDenied("Only admin can perform this action")
57 |
58 | return team_user
59 |
--------------------------------------------------------------------------------
/admin/apis/v1/__init__.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 | from apis.v1.auth import api as api_v1_auth
3 | from apis.v1.team import api as api_v1_team
4 | from apis.v1.user import api as api_v1_user
5 | from apis.v1.connection import api as api_v1_connection
6 | from apis.v1.instance_settings import api as api_v1_instance_settings
7 | from apis.v1.config import api as api_v1_config
8 |
9 | api = APIRouter(prefix="/v1")
10 | api.include_router(api_v1_auth)
11 | api.include_router(api_v1_team)
12 | api.include_router(api_v1_user)
13 | api.include_router(api_v1_connection)
14 | api.include_router(api_v1_instance_settings)
15 | api.include_router(api_v1_config)
16 |
17 |
18 | @api.get("/healthcheck", tags=["healthcheck"])
19 | async def healthcheck():
20 | return {"status": "ok"}
21 |
--------------------------------------------------------------------------------
/admin/apis/v1/instance_settings.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 | from apis import security
3 | from models.user import User
4 | from services import settings as settings_service
5 | from schemas.settings import SettingsResponseSchema, SettingsUpdateSchema
6 |
7 | api = APIRouter(prefix="/instance-settings", tags=["instance-settings"])
8 |
9 |
10 | @api.get("/", response_model=SettingsResponseSchema)
11 | async def get_settings(_=Depends(security.requires_superuser)):
12 | return await settings_service.get_instance_settings()
13 |
14 |
15 | @api.patch("/", response_model=SettingsResponseSchema)
16 | async def update_settings(
17 | data: SettingsUpdateSchema, user: User = Depends(security.requires_superuser)
18 | ):
19 | settings = await settings_service.get_instance_settings()
20 |
21 | if data.smtp_enabled is False:
22 | settings.smtp_enabled = False
23 | await settings.save()
24 | return settings
25 |
26 | for k, v in data.model_dump().items():
27 | setattr(settings, k, v)
28 |
29 | settings.updated_by = user # type: ignore
30 |
31 | await settings.save()
32 |
33 | return settings
34 |
--------------------------------------------------------------------------------
/admin/apis/v1/user.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 | from apis import security
3 |
4 | from models.user import Team, TeamUser, User
5 | from schemas.team import TeamSchema
6 | from schemas.user import (
7 | ChangePasswordSchema,
8 | TeamUserSchemaForCurrentUser,
9 | UserSchema,
10 | UserUpdateSchema,
11 | )
12 | from utils.token import generate_secret_key
13 |
14 | api = APIRouter(prefix="/user", tags=["user"])
15 |
16 |
17 | @api.get("/me", response_model=TeamUserSchemaForCurrentUser)
18 | async def current_team_user(
19 | team_user: TeamUser = Depends(security.get_current_team_user),
20 | ):
21 | return team_user
22 |
23 |
24 | @api.get("/me/teams", response_model=list[TeamSchema])
25 | async def current_user_teams(
26 | user: TeamUser = Depends(security.get_current_user),
27 | ):
28 | return await Team.filter(team_users__user=user).all()
29 |
30 |
31 | @api.patch("/me/update", response_model=UserSchema)
32 | async def update_user(
33 | data: UserUpdateSchema, user: User = Depends(security.get_current_user)
34 | ):
35 | for k, v in data.model_dump().items():
36 | if v is not None:
37 | setattr(user, k, v)
38 | await user.save()
39 | return user
40 |
41 |
42 | @api.patch("/me/change-password", response_model=UserSchema)
43 | async def change_password(
44 | data: ChangePasswordSchema, user: User = Depends(security.get_current_user)
45 | ):
46 | user.set_password(data.password)
47 | await user.save()
48 | return user
49 |
50 |
51 | @api.patch("/me/rotate-secret-key")
52 | async def rotate_secret_key(user: TeamUser = Depends(security.get_current_team_user)):
53 | user.secret_key = generate_secret_key()
54 | await user.save()
55 | return {"secret_key": user.secret_key}
56 |
--------------------------------------------------------------------------------
/admin/config/__init__.py:
--------------------------------------------------------------------------------
1 | from .settings import settings
2 |
3 | __all__ = ["settings"]
4 |
--------------------------------------------------------------------------------
/admin/config/beats.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta, UTC
2 | from models.auth import Session
3 | from models.connection import Connection, ConnectionStatus
4 | import logging
5 |
6 | logger = logging.getLogger("uvicorn.info")
7 |
8 |
9 | async def clear_expired_sessions():
10 | logger.info("Clearing expired sessions")
11 | await Session.filter(expires_at__lte=datetime.now(UTC)).delete()
12 |
13 |
14 | async def clear_unclaimed_connections():
15 | logger.info("Clearing unclaimed connections")
16 | await Connection.filter(
17 | status=ConnectionStatus.reserved.value,
18 | created_at__lte=datetime.now(UTC) - timedelta(seconds=10),
19 | ).delete()
20 |
--------------------------------------------------------------------------------
/admin/config/database.py:
--------------------------------------------------------------------------------
1 | from tortoise import Tortoise, connections
2 | from config import settings
3 |
4 | TORTOISE_MODELS = [
5 | "aerich.models",
6 | "models.auth",
7 | "models.user",
8 | "models.settings",
9 | "models.connection",
10 | ]
11 |
12 | TORTOISE_ORM = {
13 | "connections": {"default": settings.db_url},
14 | "apps": {
15 | "models": {
16 | "models": TORTOISE_MODELS,
17 | "default_connection": "default",
18 | },
19 | },
20 | }
21 |
22 |
23 | async def connect_db(generate_schemas: bool = False):
24 | await Tortoise.init(
25 | db_url=settings.db_url,
26 | modules={"models": TORTOISE_MODELS},
27 | )
28 | if generate_schemas:
29 | await Tortoise.generate_schemas()
30 |
31 |
32 | async def disconnect_db():
33 | await connections.close_all()
34 |
--------------------------------------------------------------------------------
/admin/config/enums.py:
--------------------------------------------------------------------------------
1 | from enum import Enum as BaseEnum
2 | from typing import Any
3 |
4 |
5 | class Enum(BaseEnum):
6 | @classmethod
7 | def choices(self) -> list[tuple[str, Any]]:
8 | return [(e.name, e.value) for e in self]
9 |
--------------------------------------------------------------------------------
/admin/config/settings.py:
--------------------------------------------------------------------------------
1 | from pydantic import Field
2 | from pydantic_settings import BaseSettings, SettingsConfigDict
3 | from pydantic import model_validator
4 |
5 |
6 | class Settings(BaseSettings):
7 | debug: bool = Field(default=False, alias="PORTR_ADMIN_DEBUG")
8 | db_url: str = Field(alias="PORTR_DB_URL")
9 | domain: str
10 | use_vite: bool = Field(default=False, alias="PORTR_ADMIN_USE_VITE")
11 | encryption_key: str = Field(alias="PORTR_ADMIN_ENCRYPTION_KEY")
12 |
13 | github_client_id: str | None = Field(None, alias="PORTR_ADMIN_GITHUB_CLIENT_ID")
14 | github_client_secret: str | None = Field(
15 | None, alias="PORTR_ADMIN_GITHUB_CLIENT_SECRET"
16 | )
17 |
18 | server_url: str
19 | ssh_url: str
20 |
21 | model_config = SettingsConfigDict(env_file=".env", env_prefix="PORTR_")
22 |
23 | def domain_address(self):
24 | if "localhost:" in self.domain:
25 | return f"http://{self.domain}"
26 | return f"https://{self.domain}"
27 |
28 | @model_validator(mode="after")
29 | def validate_github_auth_credentials(self):
30 | if self.github_client_id is not None and self.github_client_secret is None:
31 | raise ValueError("Github client secret is required")
32 | return self
33 |
34 |
35 | settings = Settings() # type: ignore
36 |
--------------------------------------------------------------------------------
/admin/conftest.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pytest
3 | from tortoise.contrib.test import finalizer, initializer
4 |
5 | from config.database import TORTOISE_MODELS
6 |
7 |
8 | @pytest.fixture(scope="session", autouse=True)
9 | def initialize_tests(request):
10 | db_url = os.environ.get("TORTOISE_TEST_DB", "sqlite://:memory:")
11 | initializer(TORTOISE_MODELS, db_url=db_url, app_label="models")
12 | request.addfinalizer(finalizer)
13 |
--------------------------------------------------------------------------------
/admin/migrations/models/1_20240813141115_add-password-to-user.py:
--------------------------------------------------------------------------------
1 | from tortoise import BaseDBAsyncClient
2 |
3 |
4 | async def upgrade(db: BaseDBAsyncClient) -> str:
5 | return """
6 | ALTER TABLE "user" ADD "password" VARCHAR(255);"""
7 |
8 |
9 | async def downgrade(db: BaseDBAsyncClient) -> str:
10 | return """
11 | ALTER TABLE "user" DROP COLUMN "password";"""
12 |
--------------------------------------------------------------------------------
/admin/models/auth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from tortoise import Model, fields
3 |
4 | from models import PkModelMixin, TimestampModelMixin
5 | from models.user import User
6 | from utils.token import generate_session_token
7 |
8 |
9 | class Session(PkModelMixin, TimestampModelMixin, Model): # type: ignore
10 | user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
11 | "models.User", related_name="sessions"
12 | )
13 | token = fields.CharField(
14 | max_length=255, unique=True, default=generate_session_token
15 | )
16 | expires_at = fields.DatetimeField(
17 | index=True,
18 | default=lambda: datetime.datetime.now(datetime.UTC)
19 | + datetime.timedelta(days=7),
20 | )
21 |
--------------------------------------------------------------------------------
/admin/models/connection.py:
--------------------------------------------------------------------------------
1 | from tortoise import Model, fields
2 | from config.enums import Enum
3 |
4 | from models import TimestampModelMixin
5 |
6 | from models.user import Team, TeamUser
7 | from utils.token import generate_connection_id
8 |
9 |
10 | class ConnectionType(str, Enum):
11 | http = "http"
12 | tcp = "tcp"
13 |
14 |
15 | class ConnectionStatus(str, Enum):
16 | reserved = "reserved"
17 | active = "active"
18 | closed = "closed"
19 |
20 |
21 | class Connection(TimestampModelMixin, Model):
22 | id = fields.CharField(max_length=26, pk=True, default=generate_connection_id)
23 | type = fields.CharField(max_length=255, choices=ConnectionType.choices())
24 | subdomain = fields.CharField(max_length=255, null=True)
25 | port = fields.IntField(null=True)
26 | status = fields.CharField(
27 | max_length=255,
28 | choices=ConnectionStatus.choices(),
29 | default=ConnectionStatus.reserved.value, # type: ignore
30 | index=True,
31 | )
32 | created_by: fields.ForeignKeyRelation[TeamUser] = fields.ForeignKeyField(
33 | "models.TeamUser", related_name="connections"
34 | )
35 | started_at = fields.DatetimeField(null=True)
36 | closed_at = fields.DatetimeField(null=True)
37 | team: fields.ForeignKeyRelation[Team] = fields.ForeignKeyField(
38 | "models.Team", related_name="connections"
39 | )
40 |
--------------------------------------------------------------------------------
/admin/models/settings.py:
--------------------------------------------------------------------------------
1 | from tortoise import Model, fields
2 |
3 | from models import PkModelMixin, TimestampModelMixin, EncryptedField
4 | from models.user import User
5 |
6 |
7 | class InstanceSettings(PkModelMixin, TimestampModelMixin, Model): # type: ignore
8 | smtp_enabled = fields.BooleanField(default=False)
9 | smtp_host = fields.CharField(max_length=255, null=True)
10 | smtp_port = fields.IntField(null=True)
11 | smtp_username = fields.CharField(max_length=255, null=True)
12 | smtp_password = EncryptedField(max_length=255, null=True)
13 | from_address = fields.CharField(max_length=255, null=True)
14 | add_user_email_subject = fields.CharField(max_length=255, null=True)
15 | add_user_email_body = fields.TextField(null=True)
16 |
17 | updated_by: fields.ForeignKeyRelation[User] | None = fields.ForeignKeyField(
18 | "models.User",
19 | null=True,
20 | on_delete=fields.SET_NULL,
21 | )
22 |
--------------------------------------------------------------------------------
/admin/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "portr-admin"
3 | version = "0.0.29-beta"
4 | description = "Add your description here"
5 | authors = [{ name = "amalshaji", email = "amalshajid@gmail.com" }]
6 | dependencies = [
7 | "nanoid>=2.0.0",
8 | "fastapi>=0.109.2",
9 | "uvicorn>=0.27.1",
10 | "tortoise-orm[asyncpg]>=0.20.0",
11 | "pydantic-settings>=2.2.0",
12 | "httpx>=0.26.0",
13 | "jinja2>=3.1.3",
14 | "python-slugify[unidecode]>=8.0.4",
15 | "python-ulid>=2.2.0",
16 | "apscheduler>=3.10.4",
17 | "pydantic>=2.6.1",
18 | "email-validator>=2.1.0.post1",
19 | "aiosmtplib>=3.0.1",
20 | "cryptography>=42.0.5",
21 | "aerich>=0.7.2",
22 | "passlib>=1.7.4",
23 | "argon2-cffi>=23.1.0",
24 | ]
25 | readme = "README.md"
26 | requires-python = ">= 3.8"
27 |
28 | [project.scripts]
29 | hello = "portr_admin:hello"
30 |
31 | [build-system]
32 | requires = ["hatchling"]
33 | build-backend = "hatchling.build"
34 |
35 | [tool.rye]
36 | managed = true
37 | dev-dependencies = [
38 | "pre-commit>=3.5.0",
39 | "pytest>=8.0.1",
40 | "factory-boy>=3.3.0",
41 | "pytest-asyncio>=0.23.5",
42 | "asgi-lifespan>=2.1.0",
43 | "async-factory-boy>=1.0.1",
44 | "mimesis>=14.0.0",
45 | "ipdb>=0.13.13",
46 | ]
47 |
48 | [tool.hatch.metadata]
49 | allow-direct-references = true
50 |
51 | [tool.hatch.build.targets.wheel]
52 | packages = ["."]
53 |
54 | [tool.aerich]
55 | tortoise_orm = "config.database.TORTOISE_ORM"
56 | location = "./migrations"
57 | src_folder = "./."
58 |
--------------------------------------------------------------------------------
/admin/schemas/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/schemas/__init__.py
--------------------------------------------------------------------------------
/admin/schemas/auth.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, EmailStr
2 |
3 |
4 | class LoginSchema(BaseModel):
5 | email: EmailStr
6 | password: str
7 |
--------------------------------------------------------------------------------
/admin/schemas/connection.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from pydantic import BaseModel
3 |
4 | from schemas.user import TeamUserSchemaForConnection
5 |
6 |
7 | class ConnectionSchema(BaseModel):
8 | id: str
9 | type: str
10 | subdomain: str | None
11 | port: int | None
12 | status: str
13 | created_at: datetime.datetime
14 | started_at: datetime.datetime | None
15 | closed_at: datetime.datetime | None
16 |
17 | created_by: TeamUserSchemaForConnection
18 |
19 |
20 | class ConnectionCreateSchema(BaseModel):
21 | connection_type: str
22 | secret_key: str
23 | subdomain: str | None
24 |
--------------------------------------------------------------------------------
/admin/schemas/settings.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from pydantic import BaseModel
3 |
4 | from schemas.user import UserSchema
5 |
6 |
7 | class SettingsSchemaBase(BaseModel):
8 | smtp_enabled: bool
9 | smtp_host: str | None = None
10 | smtp_port: int | None = None
11 | smtp_username: str | None = None
12 | from_address: str | None = None
13 | add_user_email_subject: str | None = None
14 | add_user_email_body: str | None = None
15 |
16 |
17 | class SettingsUpdatedBySchema(BaseModel):
18 | updated_by: UserSchema | None
19 | updated_at: datetime.datetime
20 |
21 |
22 | class SettingsUpdateSchema(SettingsSchemaBase):
23 | smtp_password: str | None = None
24 |
25 |
26 | class SettingsResponseSchema(SettingsSchemaBase, SettingsUpdatedBySchema):
27 | pass
28 |
--------------------------------------------------------------------------------
/admin/schemas/team.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, EmailStr
2 |
3 | from models.user import Role
4 |
5 |
6 | class NewTeamSchema(BaseModel):
7 | name: str
8 |
9 |
10 | class TeamSchema(BaseModel):
11 | id: int
12 | name: str
13 | slug: str
14 |
15 |
16 | class AddUserToTeamSchema(BaseModel):
17 | email: EmailStr
18 | role: Role
19 | set_superuser: bool
20 |
--------------------------------------------------------------------------------
/admin/schemas/user.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 | from models.user import Role
4 |
5 |
6 | class GithubUserSchema(BaseModel):
7 | github_avatar_url: str
8 |
9 |
10 | class UserSchema(BaseModel):
11 | email: str
12 | first_name: str | None
13 | last_name: str | None
14 | is_superuser: bool
15 |
16 |
17 | class UserSchemaForCurrentUser(UserSchema):
18 | github_user: GithubUserSchema | None
19 |
20 |
21 | class TeamUserSchemaForCurrentUser(BaseModel):
22 | id: int
23 | secret_key: str
24 | role: Role
25 |
26 | user: UserSchemaForCurrentUser
27 |
28 |
29 | class TeamUserSchemaForTeam(BaseModel):
30 | id: int
31 | role: Role
32 |
33 | user: UserSchemaForCurrentUser
34 |
35 |
36 | class AddUserToTeamResponseSchema(BaseModel):
37 | team_user: TeamUserSchemaForTeam
38 | password: str | None = None
39 |
40 |
41 | class TeamUserSchemaForConnection(BaseModel):
42 | id: int
43 |
44 | user: UserSchema
45 |
46 |
47 | class UserUpdateSchema(BaseModel):
48 | first_name: str | None = None
49 | last_name: str | None = None
50 |
51 |
52 | class ChangePasswordSchema(BaseModel):
53 | password: str
54 |
--------------------------------------------------------------------------------
/admin/scripts/generate-encryption-key.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | python -c "import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
--------------------------------------------------------------------------------
/admin/scripts/pre-deploy.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from aerich import Command # type: ignore
3 |
4 | from config.database import TORTOISE_ORM, connect_db, disconnect_db
5 | from services.settings import populate_instance_settings
6 |
7 |
8 | command = Command(tortoise_config=TORTOISE_ORM)
9 |
10 |
11 | async def run_migrations():
12 | await command.init()
13 | await command.upgrade(run_in_transaction=True)
14 |
15 |
16 | async def populate_settings():
17 | await connect_db()
18 | await populate_instance_settings()
19 | await disconnect_db()
20 |
21 |
22 | async def main():
23 | await run_migrations()
24 | await populate_settings()
25 |
26 |
27 | asyncio.run(main())
28 |
--------------------------------------------------------------------------------
/admin/scripts/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | python scripts/pre-deploy.py
4 | python main.py
5 |
--------------------------------------------------------------------------------
/admin/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/services/__init__.py
--------------------------------------------------------------------------------
/admin/services/auth.py:
--------------------------------------------------------------------------------
1 | from models.auth import Session
2 | from models.user import User
3 |
4 |
5 | async def login_user(user: User) -> str:
6 | session = await Session.create(user=user)
7 | return session.token
8 |
--------------------------------------------------------------------------------
/admin/services/connection.py:
--------------------------------------------------------------------------------
1 | from models.connection import Connection, ConnectionStatus, ConnectionType
2 | from models.user import TeamUser
3 | from utils.exception import ServiceError
4 |
5 |
6 | async def create_new_connection(
7 | type: ConnectionType,
8 | created_by: TeamUser,
9 | subdomain: str | None = None,
10 | port: int | None = None,
11 | ) -> Connection:
12 | if type == ConnectionType.http and not subdomain:
13 | raise ServiceError("subdomain is required for http connections")
14 |
15 | if type == ConnectionType.http:
16 | active_connection = await Connection.filter(
17 | subdomain=subdomain, status=ConnectionStatus.active.value
18 | ).first()
19 | if active_connection:
20 | raise ServiceError("Subdomain already in use")
21 |
22 | return await Connection.create(
23 | type=type,
24 | subdomain=subdomain if type == ConnectionType.http else None,
25 | port=port if type == ConnectionType.tcp else None,
26 | created_by=created_by,
27 | team=created_by.team,
28 | )
29 |
--------------------------------------------------------------------------------
/admin/services/settings.py:
--------------------------------------------------------------------------------
1 | from models.settings import InstanceSettings
2 | import logging
3 |
4 |
5 | DEFAULT_SMTP_ENABLED = False
6 | DEFAULT_ADD_USER_EMAIL_SUBJECT = """
7 | You've been added to team {{teamName}} on Portr!
8 | """.strip()
9 | DEFAULT_ADD_USER_EMAIL_BODY = """
10 | Hello {{email}}
11 |
12 | You've been added to team "{{teamName}}" on Portr.
13 |
14 | Get started by signing in with your github account at {{dashboardUrl}}
15 | """.strip()
16 |
17 |
18 | async def populate_instance_settings():
19 | logger = logging.getLogger()
20 | settings = await InstanceSettings.first()
21 | if not settings:
22 | logger.info("Creating default instance settings")
23 | settings = await InstanceSettings.create(
24 | smtp_enabled=DEFAULT_SMTP_ENABLED,
25 | add_user_email_subject=DEFAULT_ADD_USER_EMAIL_SUBJECT,
26 | add_user_email_body=DEFAULT_ADD_USER_EMAIL_BODY,
27 | )
28 | return settings
29 |
30 |
31 | async def get_instance_settings() -> InstanceSettings:
32 | settings = await InstanceSettings.filter().select_related("updated_by").first()
33 | if not settings:
34 | raise Exception("Instance settings not found")
35 | return settings
36 |
--------------------------------------------------------------------------------
/admin/static/Geist-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/static/Geist-Regular.woff2
--------------------------------------------------------------------------------
/admin/static/Manrope-VariableFont_wght.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/static/Manrope-VariableFont_wght.ttf
--------------------------------------------------------------------------------
/admin/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/static/favicon.ico
--------------------------------------------------------------------------------
/admin/static/favicon.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/admin/static/geist-mono-latin-400-normal.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/static/geist-mono-latin-400-normal.woff2
--------------------------------------------------------------------------------
/admin/static/icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/admin/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Portr - Expose local http, tcp or websocket connections to the public
9 | internet
10 |
11 |
17 |
18 |
19 |
20 | {% if use_vite %}
21 |
25 |
29 | {% else %}{{ vite_tags | safe }}{% endif %}
30 |
31 |
32 |
--------------------------------------------------------------------------------
/admin/tests/__init__.py:
--------------------------------------------------------------------------------
1 | from fastapi.testclient import TestClient as BaseTestClient
2 | from main import app
3 | from models.user import TeamUser, User
4 | from tests.factories import SessionFactory
5 |
6 |
7 | class TestClient:
8 | @classmethod
9 | async def get_client(cls):
10 | # async so that the signature matches the other method
11 | return BaseTestClient(app)
12 |
13 | @classmethod
14 | async def get_logged_in_client(cls, auth_user: User | TeamUser):
15 | # Separate into two methods?
16 | if isinstance(auth_user, User):
17 | user = auth_user
18 | team_user = None
19 | else:
20 | user = auth_user.user
21 | team_user = auth_user
22 |
23 | client = await cls.get_client()
24 |
25 | session = await SessionFactory.create(user=user)
26 | client.cookies["portr_session"] = session.token
27 | if team_user:
28 | client.headers["x-team-slug"] = team_user.team.slug
29 |
30 | return client
31 |
--------------------------------------------------------------------------------
/admin/tests/api_tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/tests/api_tests/__init__.py
--------------------------------------------------------------------------------
/admin/tests/factories.py:
--------------------------------------------------------------------------------
1 | from models.auth import Session
2 | from models.connection import Connection, ConnectionStatus, ConnectionType
3 | from models.user import Team, TeamUser, User
4 | from factory import SubFactory, Sequence, LazyAttribute # type: ignore
5 | from async_factory_boy.factory.tortoise import AsyncTortoiseFactory # type: ignore
6 | import mimesis
7 |
8 |
9 | class UserFactory(AsyncTortoiseFactory):
10 | class Meta:
11 | model = User
12 |
13 | email = LazyAttribute(lambda _: mimesis.Person().email())
14 |
15 |
16 | class SessionFactory(AsyncTortoiseFactory):
17 | class Meta:
18 | model = Session
19 |
20 | user = SubFactory(UserFactory)
21 |
22 |
23 | class TeamFactory(AsyncTortoiseFactory):
24 | class Meta:
25 | model = Team
26 |
27 | name = Sequence(lambda n: f"test team-{n}")
28 | slug = Sequence(lambda n: f"test-team-{n}")
29 |
30 |
31 | class TeamUserFactory(AsyncTortoiseFactory):
32 | class Meta:
33 | model = TeamUser
34 |
35 | user = SubFactory(UserFactory)
36 | team = SubFactory(TeamFactory)
37 | role = "admin"
38 |
39 |
40 | class ConnectionFactory(AsyncTortoiseFactory):
41 | class Meta:
42 | model = Connection
43 |
44 | type = ConnectionType.http
45 | subdomain = LazyAttribute(lambda _: mimesis.Person().username())
46 | status = ConnectionStatus.reserved
47 | created_by = SubFactory(TeamUserFactory)
48 |
--------------------------------------------------------------------------------
/admin/tests/service_tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/tests/service_tests/__init__.py
--------------------------------------------------------------------------------
/admin/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from utils.template_renderer import render_template
3 |
4 |
5 | @pytest.fixture
6 | def context():
7 | return {"name": "John", "age": 30}
8 |
9 |
10 | @pytest.mark.parametrize(
11 | "test_input,expected",
12 | [
13 | (
14 | "Hello, my name is {{ name }} and I am {{ age }} years old.",
15 | "Hello, my name is John and I am 30 years old.",
16 | ),
17 | (
18 | "Hello, my name is {{ name }} and I am {{ age years old.",
19 | "Hello, my name is John and I am {{ age years old.",
20 | ),
21 | ],
22 | )
23 | def test_render_template(context, test_input, expected):
24 | assert render_template(test_input, context) == expected
25 |
--------------------------------------------------------------------------------
/admin/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/admin/utils/__init__.py
--------------------------------------------------------------------------------
/admin/utils/exception.py:
--------------------------------------------------------------------------------
1 | class PortrError(Exception):
2 | def __init__(self, message: str | None = None) -> None:
3 | self.message = message
4 |
5 |
6 | class ServiceError(PortrError):
7 | pass
8 |
9 |
10 | class PermissionDenied(PortrError):
11 | pass
12 |
--------------------------------------------------------------------------------
/admin/utils/smtp.py:
--------------------------------------------------------------------------------
1 | import aiosmtplib
2 | from email.message import EmailMessage
3 | from services import settings as settings_service
4 |
5 |
6 | async def send_mail(to: str, subject: str, body: str):
7 | settings = await settings_service.get_instance_settings()
8 |
9 | message = EmailMessage()
10 | message["From"] = settings.from_address # type: ignore
11 | message["To"] = to
12 | message["Subject"] = subject
13 | message.set_content(body)
14 |
15 | await aiosmtplib.send(
16 | message,
17 | hostname=settings.smtp_host, # type: ignore
18 | port=settings.smtp_port, # type: ignore
19 | username=settings.smtp_username, # type: ignore
20 | password=settings.smtp_password, # type: ignore
21 | use_tls=True,
22 | )
23 |
--------------------------------------------------------------------------------
/admin/utils/template_renderer.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 |
4 | def render_template(template, variables):
5 | pattern = r"{{\s*([^{}]*)\s*}}"
6 |
7 | def replace(match):
8 | var_name = match.group(1).strip()
9 | return str(variables.get(var_name, match.group(0)))
10 |
11 | rendered_template = re.sub(pattern, replace, template)
12 | return rendered_template
13 |
--------------------------------------------------------------------------------
/admin/utils/token.py:
--------------------------------------------------------------------------------
1 | import string
2 | import nanoid # type: ignore
3 | from ulid import ULID
4 |
5 | NANOID_ALPHABETS = string.ascii_letters + string.digits
6 |
7 |
8 | def generate_secret_key() -> str:
9 | return f"portr_{nanoid.generate(size=36, alphabet=NANOID_ALPHABETS)}"
10 |
11 |
12 | def generate_oauth_state() -> str:
13 | return nanoid.generate(size=26)
14 |
15 |
16 | def generate_session_token() -> str:
17 | return nanoid.generate(size=32)
18 |
19 |
20 | def generate_connection_id() -> str:
21 | return str(ULID())
22 |
23 |
24 | def generate_random_password() -> str:
25 | return nanoid.generate(size=16)
26 |
--------------------------------------------------------------------------------
/admin/utils/vite.py:
--------------------------------------------------------------------------------
1 | from functools import cache
2 | import json
3 |
4 | from pathlib import Path
5 |
6 | MANIFEST_PATH = Path(__file__).parent.parent / "web/dist/static/.vite/manifest.json"
7 |
8 |
9 | @cache
10 | def generate_vite_tags() -> str:
11 | if not MANIFEST_PATH.exists():
12 | raise FileNotFoundError("manifest.json not found")
13 |
14 | manifest_json = json.loads(MANIFEST_PATH.read_text())
15 |
16 | tag = ""
17 |
18 | for style in manifest_json["index.html"]["css"]:
19 | tag += f''
20 |
21 | if manifest_json["index.html"]["file"]:
22 | tag += f''
23 |
24 | return tag.strip()
25 |
--------------------------------------------------------------------------------
/admin/web/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/admin/web/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://shadcn-svelte.com/schema.json",
3 | "style": "new-york",
4 | "tailwind": {
5 | "config": "tailwind.config.js",
6 | "css": "src/app.pcss",
7 | "baseColor": "slate"
8 | },
9 | "aliases": {
10 | "components": "$lib/components",
11 | "utils": "$lib/utils"
12 | }
13 | }
--------------------------------------------------------------------------------
/admin/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Portr
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "portr-web",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview",
10 | "check": "svelte-check --tsconfig ./tsconfig.json"
11 | },
12 | "devDependencies": {
13 | "@dicebear/collection": "^8.0.1",
14 | "@dicebear/core": "^8.0.1",
15 | "@sveltejs/vite-plugin-svelte": "^2.5.3",
16 | "@tsconfig/svelte": "^5.0.4",
17 | "autoprefixer": "^10.4.19",
18 | "bits-ui": "^0.22.0",
19 | "clsx": "^2.1.0",
20 | "formsnap": "^0.4.4",
21 | "highlight.js": "^11.9.0",
22 | "postcss": "^8.4.38",
23 | "postcss-load-config": "^4.0.2",
24 | "radix-icons-svelte": "^1.2.1",
25 | "svelte": "^4.2.19",
26 | "svelte-check": "^3.6.9",
27 | "svelte-headless-table": "^0.17.7",
28 | "svelte-highlight": "^7.6.0",
29 | "svelte-routing": "^2.12.0",
30 | "tailwind-merge": "^2.2.2",
31 | "tailwind-variants": "^0.1.20",
32 | "tailwindcss": "^3.4.3",
33 | "tslib": "^2.6.2",
34 | "typescript": "^5.4.5",
35 | "vite": "^5.4.18"
36 | },
37 | "dependencies": {
38 | "lucide-svelte": "0.352.0",
39 | "moment": "^2.30.1",
40 | "svelte-legos": "^0.2.2",
41 | "svelte-sonner": "^0.3.22",
42 | "zod": "^3.22.4"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/admin/web/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const tailwindcss = require("tailwindcss");
2 | const autoprefixer = require("autoprefixer");
3 |
4 | const config = {
5 | plugins: [
6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind,
7 | tailwindcss(),
8 | //But others, like autoprefixer, need to run after,
9 | autoprefixer,
10 | ],
11 | };
12 |
13 | module.exports = config;
14 |
--------------------------------------------------------------------------------
/admin/web/src/App.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ApiError.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 | Error
11 |
12 | {#if typeof error === "string"}
13 | {error}
14 | {:else}
15 |
16 | {#each Object.keys(error) as key}
17 | {#each Object.keys(error[key]) as message}
18 | - {error[key][message]}
19 | {/each}
20 | {/each}
21 |
22 | {/if}
23 |
24 |
25 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ConnectionStatus.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 | {#if status === "closed"}
9 | closed
10 | {:else}
11 | active
12 | {/if}
13 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ConnectionType.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 | {#if type === "http"}
9 | HTTP
10 | {:else}
11 | TCP
12 | {/if}
13 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/DateField.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
18 |
19 |
20 | {moment(Date).format("DD MMM YYYY, HH:mm:ss")}
21 |
22 |
23 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ErrorText.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 | {error}
6 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/Pagination.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
16 |
17 |
18 |
19 |
20 | {#each pages as page (page.key)}
21 | {#if page.type === "ellipsis"}
22 |
23 |
24 |
25 | {:else}
26 |
27 |
28 | {page.value}
29 |
30 |
31 | {/if}
32 | {/each}
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/copyToClipboard.svelte:
--------------------------------------------------------------------------------
1 |
19 |
20 | {#if copied}
21 |
24 | {:else}
25 |
28 | {/if}
29 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/data-table-skeleton.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/error.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
9 |
20 |
21 |
22 |
23 |
Error
24 |
25 | {error}
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/goback.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
19 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/issue-link.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/sidebarlink.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
22 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert-dialog/index.ts:
--------------------------------------------------------------------------------
1 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
2 |
3 | const Root = AlertDialogPrimitive.Root;
4 | const Trigger = AlertDialogPrimitive.Trigger;
5 |
6 | import Title from "./alert-dialog-title.svelte";
7 | import Action from "./alert-dialog-action.svelte";
8 | import Cancel from "./alert-dialog-cancel.svelte";
9 | import Portal from "./alert-dialog-portal.svelte";
10 | import Footer from "./alert-dialog-footer.svelte";
11 | import Header from "./alert-dialog-header.svelte";
12 | import Overlay from "./alert-dialog-overlay.svelte";
13 | import Content from "./alert-dialog-content.svelte";
14 | import Description from "./alert-dialog-description.svelte";
15 |
16 | export {
17 | Root,
18 | Title,
19 | Action,
20 | Cancel,
21 | Portal,
22 | Footer,
23 | Header,
24 | Trigger,
25 | Overlay,
26 | Content,
27 | Description,
28 | //
29 | Root as AlertDialog,
30 | Title as AlertDialogTitle,
31 | Action as AlertDialogAction,
32 | Cancel as AlertDialogCancel,
33 | Portal as AlertDialogPortal,
34 | Footer as AlertDialogFooter,
35 | Header as AlertDialogHeader,
36 | Trigger as AlertDialogTrigger,
37 | Overlay as AlertDialogOverlay,
38 | Content as AlertDialogContent,
39 | Description as AlertDialogDescription
40 | };
41 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert/alert-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert/alert-title.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert/alert.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/alert/index.ts:
--------------------------------------------------------------------------------
1 | import { tv, type VariantProps } from "tailwind-variants";
2 |
3 | import Root from "./alert.svelte";
4 | import Description from "./alert-description.svelte";
5 | import Title from "./alert-title.svelte";
6 |
7 | export const alertVariants = tv({
8 | base: "relative w-full rounded-sm border px-4 py-3 text-sm [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | });
20 |
21 | export type Variant = VariantProps["variant"];
22 | export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
23 |
24 | export {
25 | Root,
26 | Description,
27 | Title,
28 | //
29 | Root as Alert,
30 | Description as AlertDescription,
31 | Title as AlertTitle,
32 | };
33 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/avatar/avatar-fallback.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/avatar/avatar-image.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/avatar/avatar.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/avatar/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./avatar.svelte";
2 | import Image from "./avatar-image.svelte";
3 | import Fallback from "./avatar-fallback.svelte";
4 |
5 | export {
6 | Root,
7 | Image,
8 | Fallback,
9 | //
10 | Root as Avatar,
11 | Image as AvatarImage,
12 | Fallback as AvatarFallback
13 | };
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/badge/badge.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/badge/index.ts:
--------------------------------------------------------------------------------
1 | import { tv, type VariantProps } from "tailwind-variants";
2 |
3 | export { default as Badge } from "./badge.svelte";
4 | export const badgeVariants = tv({
5 | base: "inline-flex items-center rounded-sm border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 select-none",
6 | variants: {
7 | variant: {
8 | default:
9 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
10 | secondary:
11 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
12 | destructive:
13 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
14 | outline: "text-foreground",
15 | },
16 | },
17 | defaultVariants: {
18 | variant: "default",
19 | },
20 | });
21 |
22 | export type Variant = VariantProps["variant"];
23 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/button/button.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/card/card-content.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/card/card-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/card/card-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/card/card-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/card/card-title.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/card/card.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/card/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./card.svelte";
2 | import Content from "./card-content.svelte";
3 | import Description from "./card-description.svelte";
4 | import Footer from "./card-footer.svelte";
5 | import Header from "./card-header.svelte";
6 | import Title from "./card-title.svelte";
7 |
8 | export {
9 | Root,
10 | Content,
11 | Description,
12 | Footer,
13 | Header,
14 | Title,
15 | //
16 | Root as Card,
17 | Content as CardContent,
18 | Description as CardDescription,
19 | Footer as CardFooter,
20 | Header as CardHeader,
21 | Title as CardTitle
22 | };
23 |
24 | export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
25 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/checkbox/checkbox.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
23 |
28 | {#if isIndeterminate}
29 |
30 | {:else}
31 |
32 | {/if}
33 |
34 |
35 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/checkbox/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./checkbox.svelte";
2 | export {
3 | Root,
4 | //
5 | Root as Checkbox
6 | };
7 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/dialog-content.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
28 |
29 |
32 |
33 | Close
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/dialog-description.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/dialog-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/dialog-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/dialog-overlay.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
22 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/dialog-portal.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/dialog-title.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dialog/index.ts:
--------------------------------------------------------------------------------
1 | import { Dialog as DialogPrimitive } from "bits-ui";
2 |
3 | import Title from "./dialog-title.svelte";
4 | import Portal from "./dialog-portal.svelte";
5 | import Footer from "./dialog-footer.svelte";
6 | import Header from "./dialog-header.svelte";
7 | import Overlay from "./dialog-overlay.svelte";
8 | import Content from "./dialog-content.svelte";
9 | import Description from "./dialog-description.svelte";
10 |
11 | const Root = DialogPrimitive.Root;
12 | const Trigger = DialogPrimitive.Trigger;
13 | const Close = DialogPrimitive.Close;
14 |
15 | export {
16 | Root,
17 | Title,
18 | Portal,
19 | Footer,
20 | Header,
21 | Trigger,
22 | Overlay,
23 | Content,
24 | Description,
25 | Close,
26 | //
27 | Root as Dialog,
28 | Title as DialogTitle,
29 | Portal as DialogPortal,
30 | Footer as DialogFooter,
31 | Header as DialogHeader,
32 | Trigger as DialogTrigger,
33 | Overlay as DialogOverlay,
34 | Content as DialogContent,
35 | Description as DialogDescription,
36 | Close as DialogClose,
37 | };
38 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
15 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/dropdown-menu/index.ts:
--------------------------------------------------------------------------------
1 | import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
2 | import Item from "./dropdown-menu-item.svelte";
3 | import Label from "./dropdown-menu-label.svelte";
4 | import Content from "./dropdown-menu-content.svelte";
5 | import Shortcut from "./dropdown-menu-shortcut.svelte";
6 | import RadioItem from "./dropdown-menu-radio-item.svelte";
7 | import Separator from "./dropdown-menu-separator.svelte";
8 | import RadioGroup from "./dropdown-menu-radio-group.svelte";
9 | import SubContent from "./dropdown-menu-sub-content.svelte";
10 | import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
11 | import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
12 |
13 | const Sub = DropdownMenuPrimitive.Sub;
14 | const Root = DropdownMenuPrimitive.Root;
15 | const Trigger = DropdownMenuPrimitive.Trigger;
16 | const Group = DropdownMenuPrimitive.Group;
17 |
18 | export {
19 | Sub,
20 | Root,
21 | Item,
22 | Label,
23 | Group,
24 | Trigger,
25 | Content,
26 | Shortcut,
27 | Separator,
28 | RadioItem,
29 | SubContent,
30 | SubTrigger,
31 | RadioGroup,
32 | CheckboxItem,
33 | //
34 | Root as DropdownMenu,
35 | Sub as DropdownMenuSub,
36 | Item as DropdownMenuItem,
37 | Label as DropdownMenuLabel,
38 | Group as DropdownMenuGroup,
39 | Content as DropdownMenuContent,
40 | Trigger as DropdownMenuTrigger,
41 | Shortcut as DropdownMenuShortcut,
42 | RadioItem as DropdownMenuRadioItem,
43 | Separator as DropdownMenuSeparator,
44 | RadioGroup as DropdownMenuRadioGroup,
45 | SubContent as DropdownMenuSubContent,
46 | SubTrigger as DropdownMenuSubTrigger,
47 | CheckboxItem as DropdownMenuCheckboxItem
48 | };
49 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/input/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./input.svelte";
2 |
3 | type FormInputEvent = T & {
4 | currentTarget: EventTarget & HTMLInputElement;
5 | };
6 | export type InputEvents = {
7 | blur: FormInputEvent;
8 | change: FormInputEvent;
9 | click: FormInputEvent;
10 | focus: FormInputEvent;
11 | focusin: FormInputEvent;
12 | focusout: FormInputEvent;
13 | keydown: FormInputEvent;
14 | keypress: FormInputEvent;
15 | keyup: FormInputEvent;
16 | mouseover: FormInputEvent;
17 | mouseenter: FormInputEvent;
18 | mouseleave: FormInputEvent;
19 | paste: FormInputEvent;
20 | input: FormInputEvent;
21 | };
22 |
23 | export {
24 | Root,
25 | //
26 | Root as Input
27 | };
28 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/input/input.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
36 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/label/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./label.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Label
7 | };
8 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/label/label.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./pagination.svelte";
2 | import Content from "./pagination-content.svelte";
3 | import Item from "./pagination-item.svelte";
4 | import Link from "./pagination-link.svelte";
5 | import PrevButton from "./pagination-prev-button.svelte";
6 | import NextButton from "./pagination-next-button.svelte";
7 | import Ellipsis from "./pagination-ellipsis.svelte";
8 | export {
9 | Root,
10 | Content,
11 | Item,
12 | Link,
13 | PrevButton,
14 | NextButton,
15 | Ellipsis,
16 | //
17 | Root as Pagination,
18 | Content as PaginationContent,
19 | Item as PaginationItem,
20 | Link as PaginationLink,
21 | PrevButton as PaginationPrevButton,
22 | NextButton as PaginationNextButton,
23 | Ellipsis as PaginationEllipsis
24 | };
25 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/pagination-content.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
17 |
18 | More pages
19 |
20 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/pagination-item.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/pagination-link.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
33 | {page.value}
34 |
35 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/pagination-next-button.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
27 |
28 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/pagination-prev-button.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
27 |
28 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/pagination/pagination.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
30 |
33 |
34 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/select/index.ts:
--------------------------------------------------------------------------------
1 | import { Select as SelectPrimitive } from "bits-ui";
2 |
3 | import Label from "./select-label.svelte";
4 | import Item from "./select-item.svelte";
5 | import Content from "./select-content.svelte";
6 | import Trigger from "./select-trigger.svelte";
7 | import Separator from "./select-separator.svelte";
8 |
9 | const Root = SelectPrimitive.Root;
10 | const Group = SelectPrimitive.Group;
11 | const Input = SelectPrimitive.Input;
12 | const Value = SelectPrimitive.Value;
13 |
14 | export {
15 | Root,
16 | Item,
17 | Group,
18 | Input,
19 | Label,
20 | Value,
21 | Content,
22 | Trigger,
23 | Separator,
24 | //
25 | Root as Select,
26 | Item as SelectItem,
27 | Group as SelectGroup,
28 | Input as SelectInput,
29 | Label as SelectLabel,
30 | Value as SelectValue,
31 | Content as SelectContent,
32 | Trigger as SelectTrigger,
33 | Separator as SelectSeparator
34 | };
35 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/select/select-content.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/select/select-item.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/select/select-label.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/select/select-separator.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/select/select-trigger.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/separator/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./separator.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Separator
7 | };
8 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/separator/separator.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
23 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/skeleton/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./skeleton.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Skeleton
7 | };
8 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/skeleton/skeleton.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/switch/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./switch.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Switch
7 | };
8 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/switch/switch.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
23 |
28 |
29 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./table.svelte";
2 | import Body from "./table-body.svelte";
3 | import Caption from "./table-caption.svelte";
4 | import Cell from "./table-cell.svelte";
5 | import Footer from "./table-footer.svelte";
6 | import Head from "./table-head.svelte";
7 | import Header from "./table-header.svelte";
8 | import Row from "./table-row.svelte";
9 |
10 | export {
11 | Root,
12 | Body,
13 | Caption,
14 | Cell,
15 | Footer,
16 | Head,
17 | Header,
18 | Row,
19 | //
20 | Root as Table,
21 | Body as TableBody,
22 | Caption as TableCaption,
23 | Cell as TableCell,
24 | Footer as TableFooter,
25 | Head as TableHead,
26 | Header as TableHeader,
27 | Row as TableRow
28 | };
29 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table-body.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table-caption.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table-cell.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 | [role=checkbox]]:translate-y-[2px]",
14 | className
15 | )}
16 | {...$$restProps}
17 | on:click
18 | on:keydown
19 | >
20 |
21 | |
22 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table-head.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 | [role=checkbox]]:translate-y-[2px]",
14 | className
15 | )}
16 | {...$$restProps}
17 | >
18 |
19 | |
20 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table-row.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/table/table.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
16 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/textarea/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./textarea.svelte";
2 |
3 | type FormTextareaEvent = T & {
4 | currentTarget: EventTarget & HTMLTextAreaElement;
5 | };
6 |
7 | type TextareaEvents = {
8 | blur: FormTextareaEvent;
9 | change: FormTextareaEvent;
10 | click: FormTextareaEvent;
11 | focus: FormTextareaEvent;
12 | keydown: FormTextareaEvent;
13 | keypress: FormTextareaEvent;
14 | keyup: FormTextareaEvent;
15 | mouseover: FormTextareaEvent;
16 | mouseenter: FormTextareaEvent;
17 | mouseleave: FormTextareaEvent;
18 | paste: FormTextareaEvent;
19 | input: FormTextareaEvent;
20 | };
21 |
22 | export {
23 | Root,
24 | //
25 | Root as Textarea,
26 | type TextareaEvents,
27 | type FormTextareaEvent
28 | };
29 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/textarea/textarea.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
34 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/tooltip/index.ts:
--------------------------------------------------------------------------------
1 | import { Tooltip as TooltipPrimitive } from "bits-ui";
2 | import Content from "./tooltip-content.svelte";
3 |
4 | const Root = TooltipPrimitive.Root;
5 | const Trigger = TooltipPrimitive.Trigger;
6 |
7 | export {
8 | Root,
9 | Trigger,
10 | Content,
11 | //
12 | Root as Tooltip,
13 | Content as TooltipContent,
14 | Trigger as TooltipTrigger
15 | };
16 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/ui/tooltip/tooltip-content.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/users/avatar.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
--------------------------------------------------------------------------------
/admin/web/src/lib/components/users/user-email.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | {email}
9 | {#if is_superuser}
11 | Superuser
12 | {/if}
13 |
14 |
15 |
--------------------------------------------------------------------------------
/admin/web/src/lib/humanize.ts:
--------------------------------------------------------------------------------
1 | export const humanizeTimeMs = (ms: number): string => {
2 | const seconds = Math.floor(ms / 1000);
3 | const minutes = Math.floor(seconds / 60);
4 | const hours = Math.floor(minutes / 60);
5 | const days = Math.floor(hours / 24);
6 | const months = Math.floor(days / 30);
7 | const years = Math.floor(months / 12);
8 | if (years > 0) {
9 | return `${years} years`;
10 | }
11 | if (months > 0) {
12 | return `${months} months`;
13 | }
14 | if (days > 0) {
15 | return `${days} days`;
16 | }
17 | if (hours > 0) {
18 | return `${hours} hours`;
19 | }
20 | if (minutes > 0) {
21 | return `${minutes} minutes`;
22 | }
23 | if (seconds > 0) {
24 | return `${seconds} seconds`;
25 | }
26 | return "0 seconds";
27 | };
28 |
--------------------------------------------------------------------------------
/admin/web/src/lib/services/user.ts:
--------------------------------------------------------------------------------
1 | export const getLoggedInUser = async () => {
2 | const response = await fetch("/api/user/me");
3 | return await response.json();
4 | };
5 |
--------------------------------------------------------------------------------
/admin/web/src/lib/store.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 | import type {
3 | Connection,
4 | CurrentTeamUser,
5 | InstanceSettings,
6 | Team,
7 | TeamUser,
8 | } from "./types";
9 |
10 | export const currentUser = writable(null);
11 | export const currentUserTeams = writable([]);
12 | export const instanceSettings = writable(null);
13 |
14 | export const connections = writable([]);
15 | export const connectionsLoading = writable(false);
16 |
17 | export const users = writable([]);
18 | export const usersLoading = writable(false);
19 |
20 | export const setupScript = writable(
21 | "portr auth set --token ************************ --remote **************"
22 | );
23 |
--------------------------------------------------------------------------------
/admin/web/src/lib/types.d.ts:
--------------------------------------------------------------------------------
1 | export type Team = {
2 | id: number;
3 | name: string;
4 | slug: string;
5 | };
6 |
7 | export type CurrentTeamUser = {
8 | id: number;
9 | secret_key: String;
10 | role: String;
11 |
12 | user: CurrentUser;
13 | };
14 |
15 | export type CurrentUser = {
16 | email: string;
17 | first_name: string?;
18 | last_name: string?;
19 | is_superuser: boolean;
20 |
21 | github_user: CurrentGithubUser?;
22 | };
23 |
24 | export type CurrentGithubUser = {
25 | github_avatar_url: string;
26 | };
27 |
28 | export type TeamUser = {
29 | id: number;
30 | created_at: string;
31 | updated_at: string | null;
32 | deleted_at: string | null;
33 | team: Team;
34 | user: CurrentUser;
35 | role: "admin" | "member";
36 | secret_key: string;
37 | };
38 |
39 | export type InstanceSettings = {
40 | smtp_enabled: boolean;
41 | smtp_host: string;
42 | smtp_port: number;
43 | smtp_username: string;
44 | smtp_password: string;
45 | from_address: string;
46 | add_user_email_subject: string;
47 | add_user_email_body: string;
48 | };
49 |
50 | export type ConnectionStatus = "reserved" | "active" | "closed";
51 |
52 | export type ConnectionType = "http" | "tcp";
53 |
54 | export type Connection = {
55 | id: number;
56 | type: ConnectionType;
57 | port: number;
58 | subdomain: string;
59 | created_at: string;
60 | started_at: string | null;
61 | closed_at: string | null;
62 | status: ConnectionStatus;
63 | created_by: TeamUser;
64 | };
65 |
--------------------------------------------------------------------------------
/admin/web/src/main.ts:
--------------------------------------------------------------------------------
1 | import "./app.pcss";
2 | import App from "./App.svelte";
3 |
4 | const app = new App({
5 | target: document.getElementById("app"),
6 | });
7 |
8 | export default app;
9 |
--------------------------------------------------------------------------------
/admin/web/src/pages/app-layout.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
25 |
--------------------------------------------------------------------------------
/admin/web/src/pages/app/users.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
Team Members
8 |
9 |
10 |
11 | Team Members
12 | Manage your team members and their access levels
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/admin/web/src/pages/instance-settings/index.svelte:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/admin/web/src/pages/instance-settings/page.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
23 |
24 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/admin/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/admin/web/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
2 |
3 | export default {
4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
5 | // for more information about preprocessors
6 | preprocess: [vitePreprocess({})],
7 | };
8 |
--------------------------------------------------------------------------------
/admin/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/svelte/tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "useDefineForClassFields": true,
6 | "module": "ESNext",
7 | "resolveJsonModule": true,
8 | /**
9 | * Typecheck JS in `.svelte` and `.js` files by default.
10 | * Disable checkJs if you'd like to use dynamic types in JS.
11 | * Note that setting allowJs false does not prevent the use
12 | * of JS in `.svelte` files.
13 | */
14 | "allowJs": true,
15 | "checkJs": true,
16 | "isolatedModules": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "$lib": [
20 | "./src/lib"
21 | ],
22 | "$lib/*": [
23 | "./src/lib/*"
24 | ]
25 | }
26 | },
27 | "include": [
28 | "src/**/*.ts",
29 | "src/**/*.js",
30 | "src/**/*.svelte"
31 | ],
32 | "references": [
33 | {
34 | "path": "./tsconfig.node.json"
35 | }
36 | ]
37 | }
--------------------------------------------------------------------------------
/admin/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler"
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/admin/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { svelte } from "@sveltejs/vite-plugin-svelte";
3 | import path from "path";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [svelte()],
8 | build: {
9 | manifest: true,
10 | outDir: "dist/static",
11 | },
12 | base: "/static/",
13 | resolve: {
14 | alias: {
15 | $lib: path.resolve("./src/lib"),
16 | },
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/docker-compose.dev.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | admin:
3 | build:
4 | context: admin
5 | ports:
6 | - 8000:8000
7 | depends_on:
8 | - postgres
9 | env_file: .env
10 |
11 | tunnel:
12 | build:
13 | context: tunnel
14 | command: ["start"]
15 | ports:
16 | - 2222:2222
17 | - 8001:8001
18 | depends_on:
19 | - admin
20 | - postgres
21 | env_file: .env
22 |
23 | postgres:
24 | image: postgres:16.2
25 | environment:
26 | POSTGRES_USER: postgres
27 | POSTGRES_PASSWORD: postgres
28 | POSTGRES_DB: postgres
29 | ports:
30 | - 5432:5432
31 | volumes:
32 | - postgres_data:/var/lib/postgresql/data
33 |
34 | volumes:
35 | postgres_data: {}
36 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 | # generated types
4 | .astro/
5 |
6 | # dependencies
7 | node_modules/
8 |
9 | # logs
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
--------------------------------------------------------------------------------
/docs/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
--------------------------------------------------------------------------------
/docs/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["astro-build.astro-vscode"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/docs/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "command": "./node_modules/.bin/astro dev",
6 | "name": "Development server",
7 | "request": "launch",
8 | "type": "node-terminal"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "scripts": {
6 | "dev": "astro dev",
7 | "start": "astro dev",
8 | "build": "astro check && astro build",
9 | "preview": "astro preview",
10 | "astro": "astro"
11 | },
12 | "dependencies": {
13 | "@astrojs/check": "^0.9.4",
14 | "@astrojs/starlight": "^0.30.3",
15 | "@astrojs/starlight-tailwind": "^3.0.0",
16 | "@astrojs/tailwind": "^5.1.4",
17 | "@fontsource/geist-mono": "^5.0.3",
18 | "@fontsource/geist-sans": "^5.1.0",
19 | "astro": "^5.1.1",
20 | "astro-embed": "^0.6.2",
21 | "sharp": "^0.32.6",
22 | "tailwindcss": "^3.4.9",
23 | "typescript": "^5.5.4"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/docs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/docs/public/favicon.ico
--------------------------------------------------------------------------------
/docs/public/icon.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/docs/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/docs/public/og.png
--------------------------------------------------------------------------------
/docs/src/assets/cloudflare.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/docs/src/assets/cloudflare.png
--------------------------------------------------------------------------------
/docs/src/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/docs/src/assets/icon.png
--------------------------------------------------------------------------------
/docs/src/assets/icon.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/docs/src/components/Head.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import type { Props } from "@astrojs/starlight/props";
3 | import Default from "@astrojs/starlight/components/Head.astro";
4 | ---
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/src/content/config.ts:
--------------------------------------------------------------------------------
1 | import { defineCollection } from 'astro:content';
2 | import { docsSchema } from '@astrojs/starlight/schema';
3 |
4 | export const collections = {
5 | docs: defineCollection({ schema: docsSchema() }),
6 | };
7 |
--------------------------------------------------------------------------------
/docs/src/content/docs/client/http-tunnel.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: HTTP tunnel
3 | description: Learn how to start a http tunnel using portr
4 | ---
5 |
6 | Use the following command to tunnel an http connection.
7 |
8 | ```bash
9 | portr http 9000
10 | ```
11 |
12 | Or start it with a custom subdomain
13 |
14 | ```bash
15 | portr http 9000 --subdomain amal-test
16 | ```
17 |
18 | This also starts the portr inspector on [http://localhost:7777](http://localhost:7777), where you can inspect and replay http requests.
19 |
--------------------------------------------------------------------------------
/docs/src/content/docs/client/installation.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Portr client setup
3 | description: Learn how to setup portr client locally
4 | ---
5 |
6 | import { Aside } from "@astrojs/starlight/components";
7 |
8 | ### Install the client
9 |
10 | Using the install script:
11 |
12 | ```shell
13 | curl -sSf https://install.portr.dev | sh
14 | ```
15 |
16 | Or install the client using homebrew:
17 |
18 | ```shell
19 | brew install amalshaji/taps/portr
20 | ```
21 |
22 | You can also download the client binary from [github releases](https://github.com/amalshaji/portr/releases).
23 |
24 | Once the download/install is complete, login to the portr admin dashboard and copy the setup command from the overview page. It'll look like this.
25 |
26 | ```bash
27 | portr auth set --token {your_token} --remote {your_domain}
28 | ```
29 |
30 | For more details, run `portr --help`.
31 |
--------------------------------------------------------------------------------
/docs/src/content/docs/client/tcp-tunnel.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: TCP tunnel
3 | description: Learn how to start a tcp tunnel using portr
4 | ---
5 |
6 | Use the following command to tunnel an http connection.
7 |
8 | ```bash
9 | portr tcp 9000
10 | ```
11 |
--------------------------------------------------------------------------------
/docs/src/content/docs/client/templates.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Tunnel templates
3 | description: Learn how to setup tunnel templates to reuse tunnel settings
4 | ---
5 |
6 | ### Why templates
7 |
8 | - Run multiple tunnels at the same time.
9 | - If you use certain subdomains/port regularly, it is easier to create them as services and reuse using simple commands.
10 |
11 | Open the portr client config file by running the following command
12 |
13 | ```bash
14 | portr config edit
15 | ```
16 |
17 | This should open a file with the following contents
18 |
19 | ```yaml
20 | server_url: example.com
21 | ssh_url: example.com:2222
22 | secret_key: { your-secret-key }
23 | tunnels:
24 | - name: portr
25 | subdomain: portr
26 | port: 4321
27 | ```
28 |
29 | You can create tunnel templates under the tunnels key to quickly start them.
30 | For example, you can start the portr tunnel using `portr start portr`. You can also add a tcp connection by specifying the type of the connection.
31 |
32 | ```yaml
33 | tunnels:
34 | - name: portr
35 | subdomain: portr
36 | port: 4321
37 | - name: pg
38 | subdomain: portr
39 | port: 5432
40 | type: tcp
41 | ```
42 |
43 | And start multiple services by using the command `portr start portr pg`.
44 |
45 | To start all the services, use the command `portr start`.
46 |
47 | For more details, run `portr --help`.
48 |
--------------------------------------------------------------------------------
/docs/src/content/docs/client/websocket-tunnel.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Websocket tunnel
3 | description: Learn how to start a websocket tunnel using portr
4 | ---
5 |
6 | Websocket uses http protocol for the initial handshake and switches to use a tcp connection. So, an http tunnel would work.
7 |
--------------------------------------------------------------------------------
/docs/src/content/docs/getting-started.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Portr
3 | description: What is portr?
4 | ---
5 |
6 | import { YouTube } from "astro-embed";
7 |
8 | Portr is a self-hosted tunnel solution designed for teams. It lets you expose local http, tcp or websocket connections to the internet.
9 |
10 | You can use the client to quickly tunnel http, tcp or websocket connections
11 |
12 | ```bash
13 | portr http 9000
14 | ```
15 |
16 | This command exposes your local http server running at port `9000` on a public https url.
17 |
18 | Checkout how to quickly create a tunnel and inspect requests using portr inspector in the following video
19 |
20 |
21 |
22 | Here's a quick walk-through of the admin dashboard, where you can monitor connections, create and manage teams and team members.
23 |
24 |
25 |
--------------------------------------------------------------------------------
/docs/src/content/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Portr
3 | description: Expose local http, tcp or websocket connections to the public internet
4 | template: splash
5 | hero:
6 | tagline: Expose local http, tcp or websocket connections to the public internet
7 | image:
8 | alt: A bubbly Portr
9 | file: ../../assets/icon.png
10 | actions:
11 | - text: Read the docs
12 | link: /getting-started/
13 | icon: open-book
14 | variant: primary
15 | - text: GitHub
16 | link: https://github.com/amalshaji/portr
17 | icon: github
18 | variant: secondary
19 | attrs:
20 | target: _blank
21 | - text: Hacker News (172)
22 | link: https://news.ycombinator.com/item?id=39913197
23 | icon: star
24 | variant: secondary
25 | attrs:
26 | target: _blank
27 | ---
28 |
29 | import { Card, CardGrid } from "@astrojs/starlight/components";
30 | import { YouTube } from "astro-embed";
31 |
32 | ## Features
33 |
34 |
35 |
36 | Admin dashboard to monitor connections, manage teams and members.{" "}
37 |
38 |
39 |
40 | Portr inspector to inspect and replay http requests.{" "}
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/docs/src/content/docs/local-development/admin.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Setup admin in local
3 | description: Learn how to setup portr admin for local development
4 | ---
5 |
6 | The admin is built using python for the backend and svelte for the frontend.
7 |
8 | ## Requirements
9 |
10 | - [rye](https://github.com/astral-sh/rye) (0.43.0+)
11 | - node (20+)
12 | - pnpm (9.15.0+)
13 | - postgres (16+)
14 |
15 | ## Frontend setup
16 |
17 | ### Installation
18 |
19 | ```shell
20 | make installclient
21 | ```
22 |
23 | ### Start the client
24 |
25 | ```shell
26 | make runclient
27 | ```
28 |
29 | ## Backend setup
30 |
31 | Inside the admin folder, run the following
32 |
33 | ```shell
34 | rye sync
35 | ```
36 |
37 | This sets up the relevant python version and install packages in a virtual environment.
38 |
39 | Create a new `.env` using the `.env.template` file. Make sure the following environment variables are setup,
40 |
41 | - PORTR_ADMIN_ENCRYPTION_KEY
42 | - PORTR_ADMIN_GITHUB_CLIENT_ID (only required if you need GitHub auth)
43 | - PORTR_ADMIN_GITHUB_CLIENT_SECRET
44 |
45 | ### Start the server
46 |
47 | ```shell
48 | make runserver
49 | ```
50 |
51 | This should run the migrations and start the server. You can access the server at [http://localhost:8000](http://localhost:8000)
52 |
53 | For more commands, check out the [admin makefile](https://github.com/amalshaji/portr/blob/main/admin/Makefile).
54 |
55 | For settings, check out the [admin config file](https://github.com/amalshaji/portr/blob/main/admin/config/settings.py).
56 |
--------------------------------------------------------------------------------
/docs/src/content/docs/local-development/portr-client.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Setup portr client in local
3 | description: Learn how to setup portr client for local development
4 | ---
5 |
6 | The portr server is built using go for the backend and svelte for the portr inspector.
7 |
8 | ## Requirements
9 |
10 | - go (1.23+)
11 | - node (20+)
12 | - pnpm (9.15.0+)
13 | - [admin server](/local-development/admin/)
14 | - [tunnel server](/local-development/tunnel-server/)
15 |
16 | ## Frontend setup
17 |
18 | ### Installation
19 |
20 | ```shell
21 | make installclient
22 | ```
23 |
24 | ### Start the client
25 |
26 | ```shell
27 | make runclient
28 | ```
29 |
30 | ## Cli setup
31 |
32 | Build the binary
33 |
34 | ```shell
35 | make buildcli
36 | ```
37 |
38 | Login to the admin, copy your secret key and add it to client.dev.yaml.
39 |
40 | Start the tunnel connection
41 |
42 | ```shell
43 | ./portr -c client.dev.yaml http 9999
44 | ```
45 |
--------------------------------------------------------------------------------
/docs/src/content/docs/local-development/tunnel-server.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Setup tunnel server in local
3 | description: Learn how to setup portr tunnel for local development
4 | ---
5 |
6 | The tunnel server is built using go. It uses ssh remote port forwarding to tunnel http/tcp connections.
7 |
8 | ## Requirements
9 |
10 | - go (1.23+)
11 | - postgres (16+)
12 |
13 | ## Setup
14 |
15 | Create a new `.env` using the `.env.template` file.
16 |
17 | ### Start the server
18 |
19 | ```shell
20 | make runserver
21 | ```
22 |
23 | You should see the following message
24 |
25 | ```shell
26 | time=2024-03-29T19:16:35.023+05:30 level=INFO msg="starting SSH server" port=:2222
27 | time=2024-03-29T19:16:35.023+05:30 level=INFO msg="starting proxy server" port=:8001
28 | time=2024-03-29T19:16:35.023+05:30 level=INFO msg="Starting 1 cron jobs"
29 | ```
30 |
31 | This starts the ssh server on port `:2222` and proxy server on port `:8001`
32 |
33 | For all configuration variables, check out the [tunnel server config file](https://github.com/amalshaji/portr/blob/main/tunnel/internal/server/config/config.go).
34 |
--------------------------------------------------------------------------------
/docs/src/content/docs/server/cloudflare-api-token.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Cloudflare API token
3 | description: Getting a cloudflare API token for wildcard certificates
4 | ---
5 |
6 | import { Steps } from '@astrojs/starlight/components';
7 |
8 | :::note
9 | If you're on route53, go [here](/resources/route53/)
10 | :::
11 |
12 | Cloudflare API token is required by caddy for provisioning SSL certificates for wildcard subdomains.
13 |
14 |
15 | 1. Go to [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens)
16 | 2. Create a new token
17 | 3. Set the following permissions
18 |
19 | ```
20 | Zone - DNS - Read
21 | Zone - DNS - Edit
22 | ```
23 |
24 | 4. Set the zone resources
25 |
26 | ```
27 | Include - Specific zone - {your domain}
28 | ```
29 |
30 |
31 | 
32 |
--------------------------------------------------------------------------------
/docs/src/content/docs/server/github-oauth-app.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Github oauth app (optional)
3 | description: Learn how to setup a github oauth app for portr admin login
4 | ---
5 |
6 | import { Steps } from '@astrojs/starlight/components';
7 |
8 |
9 | Along with email-password based login, you can also setup Github oauth for login.
10 |
11 | For the rest of this guide, we'll assume `example.com` as your domain.
12 |
13 |
14 | 1. Go to [https://github.com/settings/applications/new](https://github.com/settings/applications/new)
15 | 2. Enter the homepage URL as [https://example.com](https://example.com)
16 | 3. Enter the callback URL as [https://example.com/api/v1/auth/github/callback](https://example.com/api/v1/auth/github/callback)
17 | 4. Click on `Register application`
18 |
--------------------------------------------------------------------------------
/docs/src/content/docs/server/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Portr server setup
3 | description: Guide to setting up portr server
4 | ---
5 |
6 | ### Prerequisites
7 |
8 | - A virtual machine with docker installed (Hetzner 4GB 2 vCPU is cheap)
9 | - DNS records for example.com (or your domain)
10 | | Type | Name | Value |
11 | |---|---|---|
12 | | A | @ | your-server-ipv4 |
13 | | A | * | your-server-ipv4 |
14 | - [Cloudflare API token](/server/cloudflare-api-token/) - Required for wildcard subdomain SSL setup (or [Route53](/resources/route53/))
15 | - [Github oauth app credentials](/server/github-oauth-app/) - For admin dashboard login (**optional**)
16 | - Port `2222` open on the server to accept incoming ssh connections
17 | - Port range `30001-40001` open on the server to accept incoming tcp connections (only if you intend to use tcp tunnels)
18 |
--------------------------------------------------------------------------------
/docs/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/docs/src/fonts/Geist-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/docs/src/fonts/Geist-Regular.woff2
--------------------------------------------------------------------------------
/docs/src/fonts/font-face.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Geist Sans';
3 | /* Use a relative path to the local font file in `url()`. */
4 | src: url('./Geist-Regular.woff2') format('woff2');
5 | font-weight: normal;
6 | font-style: normal;
7 | font-display: swap;
8 | }
--------------------------------------------------------------------------------
/docs/src/styles/custom.css:
--------------------------------------------------------------------------------
1 | /* Dark mode colors. */
2 | :root {
3 | --sl-color-accent-low: #00273d;
4 | --sl-color-accent: #0071a7;
5 | --sl-color-accent-high: #92d1fe;
6 | --sl-color-white: #ffffff;
7 | --sl-color-gray-1: #e7eff2;
8 | --sl-color-gray-2: #bac4c8;
9 | --sl-color-gray-3: #7b8f96;
10 | --sl-color-gray-4: #495c62;
11 | --sl-color-gray-5: #2a3b41;
12 | --sl-color-gray-6: #182a2f;
13 | --sl-color-black: #121a1c;
14 | }
15 | /* Light mode colors. */
16 | :root[data-theme='light'] {
17 | --sl-color-accent-low: #b0deff;
18 | --sl-color-accent: #0073aa;
19 | --sl-color-accent-high: #003653;
20 | --sl-color-white: #121a1c;
21 | --sl-color-gray-1: #182a2f;
22 | --sl-color-gray-2: #2a3b41;
23 | --sl-color-gray-3: #495c62;
24 | --sl-color-gray-4: #7b8f96;
25 | --sl-color-gray-5: #bac4c8;
26 | --sl-color-gray-6: #e7eff2;
27 | --sl-color-gray-7: #f3f7f9;
28 | --sl-color-black: #ffffff;
29 | }
--------------------------------------------------------------------------------
/docs/src/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/docs/tailwind.config.mjs:
--------------------------------------------------------------------------------
1 | import starlightPlugin from "@astrojs/starlight-tailwind";
2 |
3 | const accent = {
4 | 200: "#dfc0bb",
5 | 600: "#a15046",
6 | 900: "#4a2722",
7 | 950: "#341d1a",
8 | };
9 | const gray = {
10 | 100: "#f6f6f6",
11 | 200: "#eeeeee",
12 | 300: "#c2c2c2",
13 | 400: "#8b8b8b",
14 | 500: "#585858",
15 | 700: "#383838",
16 | 800: "#272727",
17 | 900: "#181818",
18 | };
19 |
20 | /** @type {import('tailwindcss').Config} */
21 | export default {
22 | content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
23 | theme: {
24 | extend: {
25 | colors: {
26 | accent,
27 | gray,
28 | },
29 | fontFamily: {
30 | sans: ["Geist Sans"],
31 | mono: ["Geist Mono"],
32 | },
33 | },
34 | },
35 | plugins: [starlightPlugin()],
36 | };
37 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/strict"
3 | }
--------------------------------------------------------------------------------
/e2e/setup_test_data.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/e2e/setup_test_data.py
--------------------------------------------------------------------------------
/tunnel/.air.toml:
--------------------------------------------------------------------------------
1 | root = "."
2 | testdata_dir = "testdata"
3 | tmp_dir = "tmp"
4 |
5 | [build]
6 | args_bin = []
7 | bin = "./tmp/main start"
8 | cmd = "go build -o ./tmp/main cmd/portrd/main.go"
9 | delay = 1000
10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"]
11 | exclude_file = []
12 | exclude_regex = ["_test.go"]
13 | exclude_unchanged = false
14 | follow_symlink = false
15 | full_bin = ""
16 | include_dir = []
17 | include_ext = ["go", "tpl", "tmpl", "html"]
18 | include_file = []
19 | kill_delay = "0s"
20 | log = "build-errors.log"
21 | poll = false
22 | poll_interval = 0
23 | post_cmd = []
24 | pre_cmd = []
25 | rerun = false
26 | rerun_delay = 500
27 | send_interrupt = false
28 | stop_on_error = false
29 |
30 | [color]
31 | app = ""
32 | build = "yellow"
33 | main = "magenta"
34 | runner = "green"
35 | watcher = "cyan"
36 |
37 | [log]
38 | main_only = false
39 | time = false
40 |
41 | [misc]
42 | clean_on_exit = false
43 |
44 | [screen]
45 | clear_on_rebuild = false
46 | keep_scroll = true
47 |
--------------------------------------------------------------------------------
/tunnel/.dockerignore:
--------------------------------------------------------------------------------
1 | tmp
2 | portr
3 | .env
4 | internal/client
--------------------------------------------------------------------------------
/tunnel/.env.template:
--------------------------------------------------------------------------------
1 | PORTR_DB_URL="postgres://postgres:postgres@localhost:5432/postgres"
2 | PORTR_DOMAIN=localhost:8000
3 | PORTR_USE_LOCALHOST=true
4 |
--------------------------------------------------------------------------------
/tunnel/.gitignore:
--------------------------------------------------------------------------------
1 | keys
2 | .env
3 |
--------------------------------------------------------------------------------
/tunnel/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.23 AS builder
2 |
3 | WORKDIR /app
4 |
5 | COPY go.mod go.sum /app/
6 |
7 | RUN go mod download
8 |
9 | COPY . /app/
10 |
11 | ARG VERSION=dev
12 |
13 | RUN CGO_ENABLED=1 go build -ldflags="-s -w -linkmode external -extldflags \"-static\" -X main.version=${VERSION}" -o portrd ./cmd/portrd
14 |
15 | FROM alpine:3.20 AS final
16 |
17 | LABEL maintainer="Amal Shaji" \
18 | org.opencontainers.image.title="Portr Tunnel" \
19 | org.opencontainers.image.description="Tunnel server for Portr" \
20 | org.opencontainers.image.source="https://github.com/amalshaji/portr"
21 |
22 | WORKDIR /app
23 |
24 | COPY --from=builder /app/portrd /app/
25 |
26 | ENTRYPOINT ["./portrd"]
27 |
--------------------------------------------------------------------------------
/tunnel/Makefile:
--------------------------------------------------------------------------------
1 | buildcli:
2 | go build -o portr cmd/portr/*.go
3 |
4 | installclient:
5 | pnpm --dir internal/client/dashboard/ui install
6 |
7 | runclient:
8 | pnpm --dir internal/client/dashboard/ui dev
9 |
10 | buildclient:
11 | pnpm --dir internal/client/dashboard/ui build
12 |
--------------------------------------------------------------------------------
/tunnel/client.dev.yaml:
--------------------------------------------------------------------------------
1 | server_url: localhost:8000
2 | ssh_url: localhost:2222
3 | tunnel_url: localhost:8001
4 | secret_key:
5 | use_vite: true
6 | use_localhost: true
7 | debug: true
8 | tunnels:
9 | - name: portr
10 | subdomain: portr
11 | port: 4321
12 | - name: amal-test
13 | subdomain: amal-test
14 | port: 9000
15 |
--------------------------------------------------------------------------------
/tunnel/cmd/portr/auth.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/amalshaji/portr/internal/client/config"
7 | "github.com/labstack/gommon/color"
8 |
9 | "github.com/urfave/cli/v2"
10 | )
11 |
12 | func authCmd() *cli.Command {
13 | return &cli.Command{
14 | Name: "auth",
15 | Usage: "Setup portr cli auth",
16 | Subcommands: []*cli.Command{
17 | {
18 | Name: "set",
19 | Usage: "Set the cli auth token",
20 | Flags: []cli.Flag{
21 | &cli.StringFlag{
22 | Name: "token",
23 | Aliases: []string{"t"},
24 | Usage: "The auth token",
25 | Required: true,
26 | },
27 | &cli.StringFlag{
28 | Name: "remote",
29 | Aliases: []string{"r"},
30 | Usage: "The remote server url",
31 | Required: true,
32 | },
33 | },
34 | Action: func(c *cli.Context) error {
35 | err := config.GetConfig(c.String("token"), c.String("remote"))
36 | if err != nil {
37 | return err
38 | }
39 |
40 | fmt.Println(color.Green("Cli auth success!"))
41 | return nil
42 | },
43 | },
44 | },
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/tunnel/cmd/portr/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/amalshaji/portr/internal/client/config"
5 | "github.com/urfave/cli/v2"
6 | )
7 |
8 | func configCmd() *cli.Command {
9 | return &cli.Command{
10 | Name: "config",
11 | Usage: "Edit the portr config file",
12 | Subcommands: []*cli.Command{
13 | {
14 | Name: "edit",
15 | Usage: "Edit the default config file",
16 | Action: func(c *cli.Context) error {
17 | return config.EditConfig()
18 | },
19 | },
20 | },
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/tunnel/cmd/portr/http.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 |
7 | "github.com/amalshaji/portr/internal/client/config"
8 | "github.com/amalshaji/portr/internal/constants"
9 | "github.com/urfave/cli/v2"
10 | )
11 |
12 | func httpCmd() *cli.Command {
13 | return &cli.Command{
14 | Name: "http",
15 | Usage: "Expose http/ws port",
16 | Flags: []cli.Flag{
17 | &cli.StringFlag{
18 | Name: "subdomain",
19 | Aliases: []string{"s"},
20 | Usage: "Subdomain to tunnel to",
21 | },
22 | },
23 | Action: func(c *cli.Context) error {
24 | portStr := c.Args().First()
25 |
26 | port, err := strconv.Atoi(portStr)
27 | if err != nil {
28 | return fmt.Errorf("please specify a valid port")
29 | }
30 |
31 | return startTunnels(c, &config.Tunnel{
32 | Port: port,
33 | Subdomain: c.Args().Get(2), // temp fix
34 | Type: constants.Http,
35 | })
36 | },
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/tunnel/cmd/portr/start.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 |
6 | "os"
7 | "os/signal"
8 | "syscall"
9 |
10 | "github.com/amalshaji/portr/internal/client/client"
11 | "github.com/amalshaji/portr/internal/client/config"
12 | "github.com/amalshaji/portr/internal/client/dashboard"
13 | "github.com/amalshaji/portr/internal/client/db"
14 | "github.com/urfave/cli/v2"
15 | )
16 |
17 | func startTunnels(c *cli.Context, tunnelFromCli *config.Tunnel) error {
18 | config, err := config.Load(c.String("config"))
19 | if err != nil {
20 | return err
21 | }
22 |
23 | db := db.New(&config)
24 |
25 | _c := client.NewClient(&config, db)
26 |
27 | if tunnelFromCli != nil {
28 | tunnelFromCli.SetDefaults()
29 | if err := tunnelFromCli.Validate(); err != nil {
30 | return err
31 | }
32 | _c.ReplaceTunnelsFromCli(*tunnelFromCli)
33 | err = _c.Start(c.Context)
34 | } else {
35 | if err := config.Validate(); err != nil {
36 | return err
37 | }
38 | err = _c.Start(c.Context, c.Args().Slice()...)
39 | }
40 |
41 | if err != nil {
42 | return err
43 | }
44 |
45 | dash := dashboard.New(db, _c.GetConfig())
46 | go func() {
47 | if err := dash.Start(); err != nil {
48 | log.Fatalf("Failed to start dashboard server: error: %v", err)
49 | }
50 | }()
51 |
52 | signalCh := make(chan os.Signal, 1)
53 | signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)
54 | <-signalCh
55 |
56 | _c.Shutdown(c.Context)
57 | dash.Shutdown()
58 | return nil
59 | }
60 |
61 | func startCmd() *cli.Command {
62 | return &cli.Command{
63 | Name: "start",
64 | Usage: "Start the tunnels from the config file",
65 | Action: func(c *cli.Context) error {
66 | return startTunnels(c, nil)
67 | },
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tunnel/cmd/portr/tcp.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 |
7 | "github.com/amalshaji/portr/internal/client/config"
8 | "github.com/amalshaji/portr/internal/constants"
9 | "github.com/urfave/cli/v2"
10 | )
11 |
12 | func tcpCmd() *cli.Command {
13 | return &cli.Command{
14 | Name: "tcp",
15 | Usage: "Expose tcp port",
16 | Action: func(c *cli.Context) error {
17 | portStr := c.Args().First()
18 |
19 | port, err := strconv.Atoi(portStr)
20 | if err != nil {
21 | return fmt.Errorf("please specify a valid port")
22 | }
23 |
24 | return startTunnels(c, &config.Tunnel{
25 | Port: port,
26 | Subdomain: "",
27 | Type: constants.Tcp,
28 | })
29 | },
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tunnel/cmd/portrd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log"
6 | "os"
7 | "os/signal"
8 | "syscall"
9 |
10 | "github.com/amalshaji/portr/internal/server/config"
11 | "github.com/amalshaji/portr/internal/server/cron"
12 | "github.com/amalshaji/portr/internal/server/db"
13 | "github.com/amalshaji/portr/internal/server/proxy"
14 | "github.com/amalshaji/portr/internal/server/service"
15 | sshd "github.com/amalshaji/portr/internal/server/ssh"
16 | "github.com/urfave/cli/v2"
17 | )
18 |
19 | // Set at build time
20 | var version = "0.0.0"
21 |
22 | func main() {
23 | app := &cli.App{
24 | Name: "portrd",
25 | Usage: "portr server",
26 | Version: version,
27 | Commands: []*cli.Command{
28 | {
29 | Name: "start",
30 | Usage: "Start the tunnel server",
31 | Action: func(c *cli.Context) error {
32 | start(c.String("config"))
33 | return nil
34 | },
35 | },
36 | },
37 | }
38 |
39 | if err := app.Run(os.Args); err != nil {
40 | log.Fatal(err)
41 | }
42 | }
43 |
44 | func start(configFilePath string) {
45 | config := config.Load(configFilePath)
46 |
47 | _db := db.New(&config.Database)
48 | _db.Connect()
49 |
50 | service := service.New(_db)
51 |
52 | proxyServer := proxy.New(config)
53 | sshServer := sshd.New(&config.Ssh, proxyServer, service)
54 | cron := cron.New(_db, config, service)
55 |
56 | go proxyServer.Start()
57 | defer proxyServer.Shutdown(context.TODO())
58 |
59 | go sshServer.Start()
60 | defer sshServer.Shutdown(context.TODO())
61 |
62 | go cron.Start()
63 | defer cron.Shutdown()
64 |
65 | done := make(chan os.Signal, 1)
66 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
67 |
68 | <-done
69 | }
70 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/handler/handler.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "github.com/amalshaji/portr/internal/client/config"
5 | "github.com/amalshaji/portr/internal/client/dashboard/service"
6 | "github.com/gofiber/fiber/v2"
7 | )
8 |
9 | type Handler struct {
10 | config *config.Config
11 | service *service.Service
12 | }
13 |
14 | func New(config *config.Config, service *service.Service) *Handler {
15 | return &Handler{
16 | config: config,
17 | service: service,
18 | }
19 | }
20 |
21 | func (h *Handler) RegisterTunnelRoutes(group fiber.Router) {
22 | group.Get("/", h.GetTunnels)
23 | group.Get("/render/:id", h.RenderResponse)
24 | group.Get("/replay/:id", h.ReplayRequest)
25 | group.Get("/:subdomain/:port", h.GetRequests)
26 | }
27 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/amalshaji/portr/internal/client/config"
5 | "github.com/amalshaji/portr/internal/client/db"
6 | )
7 |
8 | type Service struct {
9 | db *db.Db
10 | config *config.Config
11 | }
12 |
13 | func New(db *db.Db, config *config.Config) *Service {
14 | return &Service{
15 | db: db,
16 | config: config,
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/service/tunnels.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/amalshaji/portr/internal/client/db"
5 | )
6 |
7 | func (s *Service) GetTunnels() ([]*db.Request, error) {
8 | var result []*db.Request
9 | s.db.Conn.Raw(`
10 | WITH latest_requests AS (
11 | SELECT subdomain, localport, MAX(logged_at) as max_logged_at
12 | FROM requests
13 | GROUP BY subdomain, localport
14 | )
15 | SELECT r.*
16 | FROM requests r
17 | JOIN latest_requests lr
18 | ON r.subdomain = lr.subdomain
19 | AND r.localport = lr.localport
20 | AND r.logged_at = lr.max_logged_at
21 | ORDER BY r.logged_at DESC
22 | `).Find(&result)
23 | return result, nil
24 | }
25 |
26 | func (s *Service) GetRequests(subdomain string, port string) (*[]db.Request, error) {
27 | var result []db.Request
28 | s.db.Conn.Where("subdomain = ? AND localport = ?", subdomain, port).Order("logged_at desc").Find(&result)
29 | return &result, nil
30 | }
31 |
32 | func (s *Service) GetRequestById(id string) (*db.Request, error) {
33 | var request db.Request
34 | err := s.db.Conn.Where("id = ?", id).Find(&request).Error
35 | if err != nil {
36 | return nil, err
37 | }
38 | return &request, nil
39 | }
40 |
41 | func (s *Service) ReplayRequestById(id string) error {
42 | var request db.Request
43 | err := s.db.Conn.Where("id = ?", id).Find(&request).Error
44 | if err != nil {
45 | return err
46 | }
47 | return nil
48 | }
49 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Portr Inspector - Inspect your portr http traffic
8 |
14 |
15 |
16 |
17 | {% if UseVite %}
18 |
22 |
26 | {% else %} {{ ViteTags }} {% endif %}
27 |
28 |
29 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/templates/templates.go:
--------------------------------------------------------------------------------
1 | package templates
2 |
3 | import "embed"
4 |
5 | //go:embed index.html
6 | var IndexTemplate embed.FS
7 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 |
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | !dist/dist.go
26 | .vite
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["svelte.svelte-vscode"]
3 | }
4 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://shadcn-svelte.com/schema.json",
3 | "style": "default",
4 | "tailwind": {
5 | "config": "tailwind.config.js",
6 | "css": "src/app.pcss",
7 | "baseColor": "gray"
8 | },
9 | "aliases": {
10 | "components": "$lib/components",
11 | "utils": "$lib/utils"
12 | },
13 | "typescript": true
14 | }
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/dist/dist.go:
--------------------------------------------------------------------------------
1 | package dist
2 |
3 | import "embed"
4 |
5 | //go:embed static/*
6 | var EmbeddedDirStatic embed.FS
7 |
8 | //go:embed static/.vite/manifest.json
9 | var ManifestString string
10 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + Svelte + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview",
10 | "check": "svelte-check --tsconfig ./tsconfig.json"
11 | },
12 | "devDependencies": {
13 | "@sveltejs/vite-plugin-svelte": "^3.1.0",
14 | "@tsconfig/svelte": "^5.0.4",
15 | "autoprefixer": "^10.4.19",
16 | "highlight.js": "^11.9.0",
17 | "postcss": "^8.4.38",
18 | "postcss-load-config": "^5.0.3",
19 | "svelte": "^4.2.19",
20 | "svelte-check": "^3.6.9",
21 | "svelte-highlight": "^7.6.1",
22 | "tailwindcss": "^3.4.3",
23 | "tslib": "^2.6.2",
24 | "typescript": "^5.4.5",
25 | "vite": "^5.4.18"
26 | },
27 | "dependencies": {
28 | "bits-ui": "^0.19.7",
29 | "clsx": "^2.1.0",
30 | "http-status-codes": "^2.3.0",
31 | "lucide-svelte": "^0.358.0",
32 | "svelte-routing": "^2.12.0",
33 | "svelte-sonner": "^0.3.22",
34 | "tailwind-merge": "^2.2.2",
35 | "tailwind-variants": "^0.2.1"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const tailwindcss = require("tailwindcss");
2 | const autoprefixer = require("autoprefixer");
3 |
4 | const config = {
5 | plugins: [
6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind,
7 | tailwindcss(),
8 | //But others, like autoprefixer, need to run after,
9 | autoprefixer,
10 | ],
11 | };
12 |
13 | module.exports = config;
14 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/public/Geist-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amalshaji/portr/4b5e8ffd95140acbcbea12b300f1cb6435df27ab/tunnel/internal/client/dashboard/ui/public/Geist-Regular.woff2
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/App.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/HttpBadge.svelte:
--------------------------------------------------------------------------------
1 |
29 |
30 |
33 | {method}
34 |
35 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/InspectorIcon.svelte:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/RenderFormUrlEncoded.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 | {#each Object.entries(dataMap) as [key, value]}
18 |
19 |
20 |
25 |
26 | {/each}
27 |
28 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/button/button.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/button/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./button.svelte";
2 | import { tv, type VariantProps } from "tailwind-variants";
3 | import type { Button as ButtonPrimitive } from "bits-ui";
4 |
5 | const buttonVariants = tv({
6 | base: "inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
7 | variants: {
8 | variant: {
9 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
10 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
11 | outline:
12 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
13 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
14 | ghost: "hover:bg-accent hover:text-accent-foreground",
15 | link: "text-primary underline-offset-4 hover:underline",
16 | },
17 | size: {
18 | default: "h-10 px-4 py-2",
19 | sm: "h-9 rounded-md px-3",
20 | lg: "h-11 rounded-md px-8",
21 | icon: "h-10 w-10",
22 | },
23 | },
24 | defaultVariants: {
25 | variant: "default",
26 | size: "default",
27 | },
28 | });
29 |
30 | type Variant = VariantProps["variant"];
31 | type Size = VariantProps["size"];
32 |
33 | type Props = ButtonPrimitive.Props & {
34 | variant?: Variant;
35 | size?: Size;
36 | };
37 |
38 | type Events = ButtonPrimitive.Events;
39 |
40 | export {
41 | Root,
42 | type Props,
43 | type Events,
44 | //
45 | Root as Button,
46 | type Props as ButtonProps,
47 | type Events as ButtonEvents,
48 | buttonVariants,
49 | };
50 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/card/card-content.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/card/card-description.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/card/card-footer.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/card/card-header.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/card/card-title.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/card/card.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/card/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./card.svelte";
2 | import Content from "./card-content.svelte";
3 | import Description from "./card-description.svelte";
4 | import Footer from "./card-footer.svelte";
5 | import Header from "./card-header.svelte";
6 | import Title from "./card-title.svelte";
7 |
8 | export { Root, Content, Description, Footer, Header, Title };
9 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/input/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./input.svelte";
2 |
3 | export type FormInputEvent = T & {
4 | currentTarget: EventTarget & HTMLInputElement;
5 | };
6 | export type InputEvents = {
7 | blur: FormInputEvent;
8 | change: FormInputEvent;
9 | click: FormInputEvent;
10 | focus: FormInputEvent;
11 | focusin: FormInputEvent;
12 | focusout: FormInputEvent;
13 | keydown: FormInputEvent;
14 | keypress: FormInputEvent;
15 | keyup: FormInputEvent;
16 | mouseover: FormInputEvent;
17 | mouseenter: FormInputEvent;
18 | mouseleave: FormInputEvent;
19 | paste: FormInputEvent;
20 | input: FormInputEvent;
21 | };
22 |
23 | export {
24 | Root,
25 | //
26 | Root as Input,
27 | };
28 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/input/input.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
36 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/label/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./label.svelte";
2 |
3 | export {
4 | Root,
5 | //
6 | Root as Label,
7 | };
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/label/label.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./table.svelte";
2 | import Body from "./table-body.svelte";
3 | import Caption from "./table-caption.svelte";
4 | import Cell from "./table-cell.svelte";
5 | import Footer from "./table-footer.svelte";
6 | import Head from "./table-head.svelte";
7 | import Header from "./table-header.svelte";
8 | import Row from "./table-row.svelte";
9 |
10 | export {
11 | Root,
12 | Body,
13 | Caption,
14 | Cell,
15 | Footer,
16 | Head,
17 | Header,
18 | Row,
19 | //
20 | Root as Table,
21 | Body as TableBody,
22 | Caption as TableCaption,
23 | Cell as TableCell,
24 | Footer as TableFooter,
25 | Head as TableHead,
26 | Header as TableHeader,
27 | Row as TableRow,
28 | };
29 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table-body.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table-caption.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table-cell.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
17 |
18 | |
19 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table-footer.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table-head.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
18 |
19 | |
20 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table-header.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table-row.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/table/table.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
16 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/tabs/index.ts:
--------------------------------------------------------------------------------
1 | import Root from "./tabs.svelte";
2 | import Content from "./tabs-content.svelte";
3 | import List from "./tabs-list.svelte";
4 | import Trigger from "./tabs-trigger.svelte";
5 |
6 | export { Root, Content, List, Trigger };
7 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/tabs/tabs-content.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 | {#if selected}
14 |
19 |
20 |
21 | {/if}
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/tabs/tabs-list.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/tabs/tabs-trigger.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/components/ui/tabs/tabs.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/store.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 | import type { Request } from "./types";
3 |
4 | export const currentRequest = writable(null);
5 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/lib/types.d.ts:
--------------------------------------------------------------------------------
1 | export type Tunnel = {
2 | Subdomain: string;
3 | Localport: number;
4 | };
5 |
6 | export type Request = {
7 | ID: string;
8 | Subdomain: string;
9 | Host: string;
10 | Localport: number;
11 | Url: string;
12 | Method: string;
13 | Headers: Record;
14 | Body: string;
15 | ResponseStatusCode: number;
16 | ResponseHeaders: Record;
17 | ResponseBody: string;
18 | IsReplayed: boolean;
19 | ParentID: string;
20 | LoggedAt: string;
21 | };
22 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/main.ts:
--------------------------------------------------------------------------------
1 | import "./app.pcss";
2 | import App from "./App.svelte";
3 |
4 | const app = new App({
5 | target: document.getElementById("app")!,
6 | });
7 |
8 | export default app;
9 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
2 |
3 | export default {
4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
5 | // for more information about preprocessors
6 | preprocess: [vitePreprocess({})],
7 | };
8 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/svelte/tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "$lib": [
7 | "./src/lib"
8 | ],
9 | "$lib/*": [
10 | "./src/lib/*"
11 | ]
12 | },
13 | "target": "ESNext",
14 | "useDefineForClassFields": true,
15 | "module": "ESNext",
16 | "resolveJsonModule": true,
17 | /**
18 | * Typecheck JS in `.svelte` and `.js` files by default.
19 | * Disable checkJs if you'd like to use dynamic types in JS.
20 | * Note that setting allowJs false does not prevent the use
21 | * of JS in `.svelte` files.
22 | */
23 | "allowJs": true,
24 | "checkJs": true,
25 | "isolatedModules": true
26 | },
27 | "include": [
28 | "src/**/*.ts",
29 | "src/**/*.js",
30 | "src/**/*.svelte"
31 | ],
32 | "references": [
33 | {
34 | "path": "./tsconfig.node.json"
35 | }
36 | ]
37 | }
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "strict": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/tunnel/internal/client/dashboard/ui/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { svelte } from "@sveltejs/vite-plugin-svelte";
3 | import path from "path";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [svelte()],
8 | build: {
9 | manifest: true,
10 | outDir: "dist/static",
11 | },
12 | base: "/static/",
13 | resolve: {
14 | alias: {
15 | $lib: path.resolve("./src/lib"),
16 | },
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/tunnel/internal/client/vite/vite.go:
--------------------------------------------------------------------------------
1 | package vite
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | )
7 |
8 | type manifest struct {
9 | IndexHTML struct {
10 | CSS []string `json:"css"`
11 | File string `json:"file"`
12 | IsEntry bool `json:"isEntry"`
13 | Src string `json:"src"`
14 | } `json:"index.html"`
15 | }
16 |
17 | func GenerateViteTags(manifestString string) string {
18 | var manifest manifest
19 | if err := json.Unmarshal([]byte(manifestString), &manifest); err != nil {
20 | log.Fatal(err)
21 | }
22 |
23 | var tags string
24 |
25 | csses := manifest.IndexHTML.CSS
26 | if len(csses) > 0 {
27 | for _, css := range csses {
28 | tags += ""
29 | }
30 | }
31 |
32 | file := manifest.IndexHTML.File
33 | if file != "" {
34 | tags += ""
35 | }
36 |
37 | return tags
38 | }
39 |
--------------------------------------------------------------------------------
/tunnel/internal/constants/constants.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | type ConnectionType string
4 |
5 | func (c *ConnectionType) String() string {
6 | return string(*c)
7 | }
8 |
9 | func (c *ConnectionType) UnmarshalYAML(unmarshal func(interface{}) error) error {
10 | var s string
11 | if err := unmarshal(&s); err != nil {
12 | return err
13 | }
14 |
15 | switch s {
16 | case "http":
17 | *c = Http
18 | case "tcp":
19 | *c = Tcp
20 | default:
21 | *c = Http
22 | }
23 |
24 | return nil
25 | }
26 |
27 | const (
28 | Http ConnectionType = "http"
29 | Tcp ConnectionType = "tcp"
30 | )
31 |
32 | const ClientUiViteDistDir = "./internal/client/dashboard/ui/dist/static/.vite/manifest.json"
33 |
--------------------------------------------------------------------------------
/tunnel/internal/server/cron/cron.go:
--------------------------------------------------------------------------------
1 | package cron
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/amalshaji/portr/internal/server/config"
8 | "github.com/amalshaji/portr/internal/server/db"
9 | "github.com/amalshaji/portr/internal/server/service"
10 | "github.com/charmbracelet/log"
11 | )
12 |
13 | type Cron struct {
14 | db *db.Db
15 | config *config.Config
16 | service *service.Service
17 | cancelFunc context.CancelFunc
18 | }
19 |
20 | func New(db *db.Db, config *config.Config, service *service.Service) *Cron {
21 | return &Cron{db: db, config: config, service: service}
22 | }
23 |
24 | func (c *Cron) Start() {
25 | ctx, cancel := context.WithCancel(context.Background())
26 | c.cancelFunc = cancel
27 |
28 | log.Info("Starting cron jobs", "count", len(crons))
29 | for _, job := range crons {
30 | ticker := time.NewTicker(job.Interval)
31 | go func(job Job) {
32 | for {
33 | select {
34 | case <-ticker.C:
35 | log.Debug("Running cron job", "name", job.Name)
36 | job.Function(c)
37 | case <-ctx.Done():
38 | ticker.Stop()
39 | return
40 | }
41 | }
42 | }(job)
43 | }
44 | }
45 |
46 | func (c *Cron) Shutdown() {
47 | c.cancelFunc()
48 | }
49 |
--------------------------------------------------------------------------------
/tunnel/internal/server/cron/tasks.go:
--------------------------------------------------------------------------------
1 | package cron
2 |
3 | import (
4 | "context"
5 | "time"
6 | )
7 |
8 | type CronFunc func(*Cron)
9 |
10 | type Job struct {
11 | Name string
12 | Interval time.Duration
13 | Function CronFunc
14 | }
15 |
16 | var crons = []Job{
17 | // {
18 | // Name: "Delete expired sessions",
19 | // Interval: 6 * time.Hour,
20 | // Function: func(c *Cron) {
21 | // if err := c.db.Queries.DeleteExpiredSessions(context.Background()); err != nil {
22 | // c.logger.Error("error deleting expired sessions", "error", err)
23 | // }
24 | // },
25 | // },
26 | // {
27 | // Name: "Delete unclaimed connections",
28 | // Interval: 10 * time.Second,
29 | // Function: func(c *Cron) {
30 | // if err := c.db.Queries.DeleteUnclaimedConnections(context.Background()); err != nil {
31 | // c.logger.Error("error deleting unclaimed connections", "error", err)
32 | // }
33 | // },
34 | // },
35 | {
36 | Name: "Ping active connections",
37 | Interval: 10 * time.Second,
38 | Function: func(c *Cron) {
39 | c.pingActiveConnections(context.Background())
40 | },
41 | },
42 | }
43 |
--------------------------------------------------------------------------------
/tunnel/internal/server/db/db.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/amalshaji/portr/internal/server/config"
7 | _ "github.com/mattn/go-sqlite3"
8 | "gorm.io/driver/postgres"
9 | "gorm.io/driver/sqlite"
10 | "gorm.io/gorm"
11 | )
12 |
13 | type Db struct {
14 | Conn *gorm.DB
15 | config *config.DatabaseConfig
16 | }
17 |
18 | func New(config *config.DatabaseConfig) *Db {
19 | return &Db{
20 | config: config,
21 | }
22 | }
23 |
24 | func (d *Db) Connect() {
25 | var err error
26 |
27 | switch d.config.Driver {
28 | case "sqlite3", "sqlite":
29 | d.Conn, err = gorm.Open(sqlite.Open(d.config.Url), &gorm.Config{})
30 | case "postgres", "postgresql":
31 | d.Conn, err = gorm.Open(postgres.Open(d.config.Url), &gorm.Config{})
32 | default:
33 | log.Fatalf("unsupported database driver: %s", d.config.Driver)
34 | }
35 |
36 | if err != nil {
37 | log.Fatal(err)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tunnel/internal/server/db/models.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type Connection struct {
8 | ID string `gorm:"primarykey"`
9 | Type string
10 | Subdomain *string
11 | Port *uint32
12 | Status string
13 | CreatedAt time.Time
14 | StartedAt *time.Time
15 | ClosedAt *time.Time
16 | CreatedByID uint
17 | CreatedBy TeamUser
18 | }
19 |
20 | func (Connection) TableName() string {
21 | return "connection"
22 | }
23 |
24 | type TeamUser struct {
25 | ID uint `gorm:"primarykey"`
26 | CreatedAt time.Time
27 | UpdatedAt time.Time
28 | SecretKey string
29 | Role string
30 | TeamID uint32
31 | UserID uint32
32 | }
33 |
34 | func (TeamUser) TableName() string {
35 | return "team_users"
36 | }
37 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/dir.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "os"
4 |
5 | func EnsureDirExists(path string) error {
6 | _, err := os.Stat(path)
7 | if os.IsNotExist(err) {
8 | err = os.MkdirAll(path, os.ModePerm)
9 | if err != nil {
10 | return err
11 | }
12 | }
13 | return nil
14 | }
15 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/dir_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | const randomFolderName = "portr_test_123456"
11 |
12 | func TestEnsureDirExists(t *testing.T) {
13 | _ = EnsureDirExists(randomFolderName)
14 | defer os.Remove(randomFolderName)
15 |
16 | _, err := os.Stat(randomFolderName)
17 | assert.Nil(t, err)
18 | }
19 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/error.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/http.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/valyala/fasttemplate"
7 | )
8 |
9 | //go:embed error-templates/local-server-not-online.html
10 | var LocalServerNotOnlineText string
11 |
12 | func LocalServerNotOnline(endpoint string) string {
13 | return LocalServerNotOnlineText
14 | }
15 |
16 | //go:embed error-templates/unregistered-subdomain.html
17 | var UnregisteredSubdomainText string
18 |
19 | func UnregisteredSubdomain(subdomain string) string {
20 | t := fasttemplate.New(UnregisteredSubdomainText, "{{", "}}")
21 | return t.ExecuteString(map[string]any{"subdomain": subdomain})
22 | }
23 |
24 | //go:embed error-templates/connection-lost.html
25 | var ConnectionLostText string
26 |
27 | func ConnectionLost() string {
28 | return ConnectionLostText
29 | }
30 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/id.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | gonanoid "github.com/matoous/go-nanoid/v2"
5 | )
6 |
7 | func GenerateTunnelSubdomain() string {
8 | id, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz", 6)
9 | return id
10 | }
11 |
12 | func GenerateOAuthState() string {
13 | id, _ := gonanoid.New(32)
14 | return id
15 | }
16 |
17 | func GenerateSessionToken() string {
18 | id, _ := gonanoid.New(32)
19 | return id
20 | }
21 |
22 | func GenerateSecretKeyForUser() string {
23 | id, _ := gonanoid.New(42)
24 | return id
25 | }
26 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/port.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | func GenerateRandomHttpPorts() []int {
4 | var startPort int = 20000
5 | var endPort int = 30000
6 |
7 | return GenerateRandomNumbers(startPort, endPort, 10)
8 | }
9 |
10 | func GenerateRandomTcpPorts() []int {
11 | var startPort int = 30001
12 | var endPort int = 40001
13 |
14 | return GenerateRandomNumbers(startPort, endPort, 10)
15 | }
16 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/random.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "math/rand"
5 | "time"
6 | )
7 |
8 | func GenerateRandomNumbers(start, end, limit int) []int {
9 | rand := rand.New(rand.NewSource(time.Now().UnixNano()))
10 |
11 | randomNumbers := make([]int, limit)
12 | for i := range limit {
13 | randomNumbers[i] = rand.Intn(end-start) + start
14 | }
15 | return randomNumbers
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/request.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "github.com/gofiber/fiber/v2"
5 | "github.com/gookit/validate"
6 | )
7 |
8 | func BodyParser(c *fiber.Ctx, bindVar any) error {
9 | if err := c.BodyParser(bindVar); err != nil {
10 | return err
11 | }
12 | v := validate.Struct(bindVar)
13 | if !v.Validate() {
14 | return v.Errors
15 | }
16 | return nil
17 | }
18 |
19 | func ErrBadRequest(c *fiber.Ctx, message any) error {
20 | return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"message": message})
21 | }
22 |
23 | func ErrInternalServerError(c *fiber.Ctx, message any) error {
24 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"message": message})
25 | }
26 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/string.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | )
7 |
8 | func Trim(input string) string {
9 | return strings.TrimSpace(input)
10 | }
11 |
12 | func Slugify(s string) string {
13 | // Convert the string to lowercase
14 | s = strings.ToLower(s)
15 |
16 | // Replace spaces with hyphens
17 | s = strings.ReplaceAll(s, " ", "-")
18 |
19 | // Remove special characters using regular expression
20 | reg := regexp.MustCompile("[^a-z0-9-]")
21 | s = reg.ReplaceAllString(s, "")
22 |
23 | // Remove consecutive hyphens
24 | s = strings.ReplaceAll(s, "--", "-")
25 |
26 | // Remove leading and trailing hyphens
27 | s = strings.Trim(s, "-")
28 |
29 | return s
30 | }
31 |
--------------------------------------------------------------------------------
/tunnel/internal/utils/subdomain.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | )
7 |
8 | func ValidateSubdomain(subdomain string) error {
9 | matched, err := regexp.Match(`^[a-zA-Z0-9][-a-zA-Z0-9_]{0,61}[a-zA-Z0-9]$`, []byte(subdomain))
10 | if err != nil {
11 | return fmt.Errorf("error validating subdomain: %v", err)
12 | }
13 | if !matched {
14 | return fmt.Errorf("invalid subdomain '%s'. Must not contain special characters other than '-', `_`", subdomain)
15 | }
16 |
17 | return nil
18 | }
19 |
--------------------------------------------------------------------------------