mirror of
https://github.com/czhu12/canine.git
synced 2025-12-21 10:49:49 -06:00
Merge pull request #388 from CanineHQ/celina__token_interface
API Tokens
This commit is contained in:
67
Gemfile
67
Gemfile
@@ -35,6 +35,7 @@ group :development, :test do
|
||||
gem "simplecov", require: false
|
||||
gem "pry", "~> 0.15.2"
|
||||
gem "rspec-rails", "~> 8.0"
|
||||
gem 'rswag-specs'
|
||||
gem "factory_bot_rails"
|
||||
gem "webmock"
|
||||
|
||||
@@ -59,58 +60,44 @@ group :test do
|
||||
gem 'shoulda-matchers', '~> 6.5'
|
||||
end
|
||||
|
||||
gem "actioncable-enhanced-postgresql-adapter", "~> 1.0"
|
||||
gem "annotate", "~> 3.2"
|
||||
gem "avo", "~> 3.25"
|
||||
gem "cron2english", "~> 0.1.7"
|
||||
gem "cssbundling-rails"
|
||||
gem "devise", "~> 4.9"
|
||||
gem "friendly_id", "~> 5.4"
|
||||
gem "jsbundling-rails"
|
||||
gem "name_of_person", github: "basecamp/name_of_person"
|
||||
gem "noticed", "~> 2.9"
|
||||
gem "omniauth-github", "~> 2.0"
|
||||
gem "pretender", "~> 0.6.0"
|
||||
gem "pundit", "~> 2.5"
|
||||
gem "good_job", "~> 4.12"
|
||||
gem "sitemap_generator", "~> 6.1"
|
||||
gem "responders", github: "heartcombo/responders", branch: "main"
|
||||
gem "dotenv", "~> 3.1"
|
||||
gem "friendly_id", "~> 5.4"
|
||||
gem "good_job", "~> 4.12"
|
||||
gem "httparty", "~> 0.23.2"
|
||||
gem "image_processing", "~> 1.13"
|
||||
gem "jsbundling-rails"
|
||||
gem "k8s-ruby", "~> 0.17.2"
|
||||
gem "kubeclient", "~> 4.12"
|
||||
gem "light-service", "~> 0.20.0"
|
||||
gem "name_of_person", github: "basecamp/name_of_person"
|
||||
gem "noticed", "~> 2.9"
|
||||
gem "octokit", "~> 10.0"
|
||||
gem "omniauth-digitalocean", "~> 0.3.2"
|
||||
gem "pagy", "~> 9.4"
|
||||
gem "oj", "~> 3.16"
|
||||
gem "omniauth", "~> 2.1"
|
||||
gem "omniauth-rails_csrf_protection", "~> 1.0"
|
||||
gem "omniauth-github", "~> 2.0"
|
||||
gem "omniauth_openid_connect", "~> 0.8"
|
||||
|
||||
gem "annotate", "~> 3.2"
|
||||
|
||||
gem "rack", "~> 3.1"
|
||||
|
||||
gem "tailwindcss-rails", "~> 2.7"
|
||||
|
||||
gem "httparty", "~> 0.23.2"
|
||||
|
||||
gem "redcarpet", "~> 3.6"
|
||||
|
||||
gem "rubyzip", "~> 3.2"
|
||||
|
||||
gem "sassc-rails", "~> 2.1"
|
||||
|
||||
gem "cron2english", "~> 0.1.7"
|
||||
|
||||
gem "avo", "~> 3.25"
|
||||
|
||||
gem "sentry-ruby", "~> 5.28"
|
||||
gem "sentry-rails", "~> 5.27"
|
||||
|
||||
gem "sys-proctable", "~> 1.3"
|
||||
|
||||
gem "omniauth-gitlab", "~> 4.1"
|
||||
|
||||
gem "actioncable-enhanced-postgresql-adapter", "~> 1.0"
|
||||
|
||||
gem "omniauth-rails_csrf_protection", "~> 1.0"
|
||||
gem "pagy", "~> 9.4"
|
||||
gem "pretender", "~> 0.6.0"
|
||||
gem "pundit", "~> 2.5"
|
||||
gem "rack", "~> 3.1"
|
||||
gem "redcarpet", "~> 3.6"
|
||||
gem 'rswag-api', '~> 2.10', '>= 2.10.1'
|
||||
gem "responders", github: "heartcombo/responders", branch: "main"
|
||||
gem "rubyzip", "~> 3.2"
|
||||
gem "sassc-rails", "~> 2.1"
|
||||
gem "sentry-rails", "~> 5.27"
|
||||
gem "sentry-ruby", "~> 5.28"
|
||||
gem "sitemap_generator", "~> 6.1"
|
||||
gem "sys-proctable", "~> 1.3"
|
||||
gem "tailwindcss-rails", "~> 2.7"
|
||||
gem 'flipper', '~> 1.2.2'
|
||||
gem 'flipper-active_record', '~> 1.2.2'
|
||||
gem 'flipper-ui', '~> 1.2.2'
|
||||
|
||||
18
Gemfile.lock
18
Gemfile.lock
@@ -298,6 +298,9 @@ GEM
|
||||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-schema (5.2.2)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
jsonpath (1.1.5)
|
||||
multi_json
|
||||
jwt (2.9.1)
|
||||
@@ -399,10 +402,6 @@ GEM
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-digitalocean (0.3.2)
|
||||
multi_json (~> 1.15)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.0)
|
||||
omniauth-github (2.0.1)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-oauth2 (~> 1.8)
|
||||
@@ -546,6 +545,14 @@ GEM
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.4)
|
||||
rswag-api (2.16.0)
|
||||
activesupport (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rswag-specs (2.16.0)
|
||||
activesupport (>= 5.2, < 8.1)
|
||||
json-schema (>= 2.2, < 6.0)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rspec-core (>= 2.14)
|
||||
rubocop (1.79.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
@@ -733,7 +740,6 @@ DEPENDENCIES
|
||||
octokit (~> 10.0)
|
||||
oj (~> 3.16)
|
||||
omniauth (~> 2.1)
|
||||
omniauth-digitalocean (~> 0.3.2)
|
||||
omniauth-github (~> 2.0)
|
||||
omniauth-gitlab (~> 4.1)
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
@@ -749,6 +755,8 @@ DEPENDENCIES
|
||||
redcarpet (~> 3.6)
|
||||
responders!
|
||||
rspec-rails (~> 8.0)
|
||||
rswag-api (~> 2.10, >= 2.10.1)
|
||||
rswag-specs
|
||||
rubocop-rails-omakase
|
||||
rubyzip (~> 3.2)
|
||||
sassc-rails (~> 2.1)
|
||||
|
||||
@@ -11,7 +11,6 @@ class Projects::DeployLatestCommit
|
||||
project = context.project
|
||||
current_user = context.current_user || project.account.owner
|
||||
if project.git?
|
||||
project_credential_provider = project.project_credential_provider
|
||||
client = Git::Client.from_project(project)
|
||||
commit = client.commits(project.branch).first
|
||||
build = project.builds.create!(
|
||||
|
||||
28
app/controllers/api_tokens_controller.rb
Normal file
28
app/controllers/api_tokens_controller.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
class ApiTokensController < ApplicationController
|
||||
def new
|
||||
@api_token = ApiToken.new(user: current_user)
|
||||
end
|
||||
|
||||
def create
|
||||
if ApiToken.create(api_token_params.merge(user: current_user))
|
||||
redirect_to api_tokens_path, notice: "API token saved"
|
||||
else
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@api_token = current_user.api_tokens.find(params[:id])
|
||||
if @api_token.destroy
|
||||
redirect_to api_tokens_path, notice: "API token deleted"
|
||||
else
|
||||
redirect_to api_tokens_path, alert: "Failed to delete API token"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_token_params
|
||||
params.require(:api_token).permit(:name, :expires_at)
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,24 @@ class ApplicationController < ActionController::Base
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
||||
rescue_from Portainer::Client::MissingCredentialError, with: :missing_portainer_credential
|
||||
|
||||
def authenticate_user!(opts = {})
|
||||
if request.headers["X-API-Key"].present?
|
||||
authenticate_with_api_token!
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_with_api_token!
|
||||
token = request.headers["X-API-Key"]
|
||||
api_token = ApiToken.find_by(access_token: token)
|
||||
if api_token.present?
|
||||
@current_user = api_token.user
|
||||
else
|
||||
render json: { error: "Invalid API token" }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
def current_account
|
||||
return nil unless user_signed_in?
|
||||
|
||||
@@ -27,10 +27,14 @@ class Projects::DeploymentsController < Projects::BaseController
|
||||
current_user:,
|
||||
skip_build: params[:skip_build] == "true"
|
||||
)
|
||||
if result.success?
|
||||
redirect_to @project, notice: "Deploying project #{@project.name}. <a class='underline' href='#{project_deployment_path(@project, result.build)}'>Follow deployment</a>".html_safe
|
||||
else
|
||||
redirect_to @project, alert: "Failed to deploy project"
|
||||
respond_to do |format|
|
||||
if result.success?
|
||||
format.html { redirect_to @project, notice: "Deploying project #{@project.name}. <a class='underline' href='#{project_deployment_path(@project, result.build)}'>Follow deployment</a>".html_safe }
|
||||
format.json { render json: { message: "Deploying project #{@project.name}." }, status: :ok }
|
||||
else
|
||||
format.html { redirect_to @project, alert: "Failed to deploy project" }
|
||||
format.json { render json: { message: "Failed to deploy project" }, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -16,14 +16,19 @@ class ProjectsController < ApplicationController
|
||||
|
||||
# Uncomment to authorize with Pundit
|
||||
# authorize @projects
|
||||
#
|
||||
end
|
||||
|
||||
def restart
|
||||
result = Projects::Restart.execute(connection: K8::Connection.new(@project, current_user))
|
||||
if result.success?
|
||||
redirect_to project_url(@project), notice: "All services have been restarted"
|
||||
else
|
||||
redirect_to project_url(@project), alert: "Failed to restart all services"
|
||||
respond_to do |format|
|
||||
if result.success?
|
||||
format.html { redirect_to project_url(@project), notice: "All services have been restarted" }
|
||||
format.json { render json: { message: "All services have been restarted" }, status: :ok }
|
||||
else
|
||||
format.html { redirect_to project_url(@project), alert: "Failed to restart all services" }
|
||||
format.json { render json: { message: "Failed to restart all services" }, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -62,4 +62,11 @@ class StaticController < ApplicationController
|
||||
def calculator
|
||||
@prices = JSON.parse(File.read(File.join(Rails.root, 'public', 'resources', 'prices.json')))
|
||||
end
|
||||
|
||||
def docs
|
||||
end
|
||||
|
||||
def swagger
|
||||
render plain: File.read(Rails.root.join('swagger', 'v1', 'swagger.yaml')), layout: false
|
||||
end
|
||||
end
|
||||
|
||||
30
app/javascript/controllers/expiry_date_controller.js
Normal file
30
app/javascript/controllers/expiry_date_controller.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["select", "dateInput"]
|
||||
|
||||
connect() {
|
||||
this.updateExpiryDate()
|
||||
}
|
||||
|
||||
updateExpiryDate() {
|
||||
const selectedOption = this.selectTarget.options[this.selectTarget.selectedIndex]
|
||||
const expiryValue = selectedOption.dataset.expiryValue
|
||||
if (expiryValue === "custom") {
|
||||
this.dateInputTarget.classList.remove("hidden")
|
||||
} else {
|
||||
this.dateInputTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
if (!expiryValue || expiryValue === "custom") {
|
||||
this.setDateValue("");
|
||||
} else {
|
||||
const date = new Date(expiryValue);
|
||||
this.setDateValue(date.toISOString().slice(0, 10));
|
||||
}
|
||||
}
|
||||
|
||||
setDateValue(value) {
|
||||
this.dateInputTarget.querySelector("input").value = value;
|
||||
}
|
||||
}
|
||||
39
app/models/api_token.rb
Normal file
39
app/models/api_token.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: api_tokens
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# access_token :string not null
|
||||
# expires_at :datetime
|
||||
# last_used_at :datetime
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_api_tokens_on_access_token (access_token) UNIQUE
|
||||
# index_api_tokens_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class ApiToken < ApplicationRecord
|
||||
belongs_to :user
|
||||
validates :user, :access_token, presence: true
|
||||
validates :access_token, uniqueness: { scope: :user_id }
|
||||
|
||||
before_validation :generate_token, if: :new_record?
|
||||
|
||||
def generate_token
|
||||
self.access_token = SecureRandom.hex(16)
|
||||
end
|
||||
|
||||
def expired?
|
||||
return false if expires_at.nil?
|
||||
|
||||
expires_at < Time.zone.now
|
||||
end
|
||||
end
|
||||
@@ -42,6 +42,7 @@ class User < ApplicationRecord
|
||||
has_many :projects, through: :accounts
|
||||
has_many :add_ons, through: :accounts
|
||||
has_many :services, through: :accounts
|
||||
has_many :api_tokens, dependent: :destroy
|
||||
attr_readonly :admin
|
||||
|
||||
# has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification"
|
||||
|
||||
43
app/views/api_tokens/_expiry_date_field.html.erb
Normal file
43
app/views/api_tokens/_expiry_date_field.html.erb
Normal file
@@ -0,0 +1,43 @@
|
||||
<%
|
||||
seven_days = 7.days.from_now
|
||||
thirty_days = 30.days.from_now
|
||||
sixty_days = 60.days.from_now
|
||||
ninety_days = 90.days.from_now
|
||||
%>
|
||||
|
||||
<div data-controller="expiry-date" class="flex flex-row gap-6">
|
||||
<div class="form-control w-full max-w-sm">
|
||||
<label class="label">
|
||||
<span class="label-text">Expiration</span>
|
||||
</label>
|
||||
<%= form.select :expiry_preset,
|
||||
[
|
||||
["7 days (#{seven_days.strftime('%b %d, %Y')})", "7_days", { data: { expiry_value: seven_days.strftime('%Y-%m-%dT%H:%M') } }],
|
||||
["30 days (#{thirty_days.strftime('%b %d, %Y')})", "30_days", { data: { expiry_value: thirty_days.strftime('%Y-%m-%dT%H:%M') } }],
|
||||
["60 days (#{sixty_days.strftime('%b %d, %Y')})", "60_days", { data: { expiry_value: sixty_days.strftime('%Y-%m-%dT%H:%M') } }],
|
||||
["90 days (#{ninety_days.strftime('%b %d, %Y')})", "90_days", { data: { expiry_value: ninety_days.strftime('%Y-%m-%dT%H:%M') } }],
|
||||
["No Expiration", "never", { data: { expiry_value: "" } }],
|
||||
["Custom", "custom", { data: { expiry_value: "custom" } }]
|
||||
],
|
||||
{ selected: "7_days" },
|
||||
{
|
||||
class: "select select-bordered",
|
||||
data: {
|
||||
expiry_date_target: "select",
|
||||
action: "change->expiry-date#updateExpiryDate"
|
||||
}
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full max-w-sm" data-expiry-date-target="dateInput">
|
||||
<label class="label">
|
||||
<span class="label-text">Select date</span>
|
||||
</label>
|
||||
<%= form.date_field :expires_at,
|
||||
value: seven_days.strftime('%Y-%m-%d'),
|
||||
class: "input input-bordered",
|
||||
data: { "expiry-date-target": "dateInput" }
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
13
app/views/api_tokens/_form.html.erb
Normal file
13
app/views/api_tokens/_form.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<%= form_with model: api_token do |form| %>
|
||||
<%= render "shared/error_messages", resource: form.object %>
|
||||
<div class="form-group">
|
||||
<%= form.label :name %>
|
||||
<%= form.text_field :name, class: "input input-bordered w-full max-w-sm", required: true, placeholder: "my-api-token" %>
|
||||
</div>
|
||||
<%= render "api_tokens/expiry_date_field", form: form %>
|
||||
|
||||
<div class="form-footer">
|
||||
<%= form.submit "Save", class: "btn btn-primary" %>
|
||||
<%= link_to "Cancel", api_tokens_path, class: "btn btn-outline" %>
|
||||
</div>
|
||||
<% end %>
|
||||
42
app/views/api_tokens/_index.html.erb
Normal file
42
app/views/api_tokens/_index.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="overflow-x-auto mb-4">
|
||||
<table class="table">
|
||||
<!-- head -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>API Token</th>
|
||||
<th>Last Used</th>
|
||||
<th>Expires At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% current_user.api_tokens.order(created_at: :desc).each do |api_token| %>
|
||||
<tr>
|
||||
<td><%= api_token.name %></td>
|
||||
<td>
|
||||
<% if api_token.created_at == api_token.updated_at %>
|
||||
<div class="cursor-pointer flex items-center gap-2" data-controller="clipboard" data-clipboard-text="<%= api_token.access_token %>">
|
||||
<%= api_token.access_token %>
|
||||
<iconify-icon icon="lucide:clipboard" height="16"></iconify-icon>
|
||||
</div>
|
||||
<span class="text-gray-500">(You can only see this once)</span>
|
||||
<% api_token.touch(:updated_at) %>
|
||||
<% else %>
|
||||
<%= api_token.access_token.first(4) %><%= "*" * [8, api_token.access_token.length - 16].min %><%= api_token.access_token.last(4) %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<%= (api_token.last_used_at.strftime("%b %d, %Y") if api_token.last_used_at) || "Never" %>
|
||||
</td>
|
||||
<td>
|
||||
<%= (api_token.expires_at.strftime("%b %d, %Y") if api_token.expires_at) || "Never" %>
|
||||
</td>
|
||||
<td>
|
||||
<%= link_to "Delete", api_token, method: :delete, class: "btn btn-error btn-sm" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
10
app/views/api_tokens/index.html.erb
Normal file
10
app/views/api_tokens/index.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<%= settings_layout do %>
|
||||
<h2 class="text-2xl font-bold">API Tokens</h2>
|
||||
<hr class="mt-3 mb-4 border-t border-base-300" />
|
||||
<%= turbo_frame_tag "api_token" do %>
|
||||
<%= render "api_tokens/index" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to "+ API Token", new_api_token_path, class: "btn btn-primary btn-sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
8
app/views/api_tokens/new.html.erb
Normal file
8
app/views/api_tokens/new.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<%= settings_layout do %>
|
||||
<%= turbo_frame_tag "api_token" do %>
|
||||
<div class="font-lg font-bold my-4">
|
||||
Add API Token
|
||||
</div>
|
||||
<%= render "api_tokens/form", api_token: @api_token %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -5,10 +5,22 @@
|
||||
<%= turbo_frame_tag "provider" do %>
|
||||
<%= render "providers/index" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to "+ Github Credentials", new_provider_path(provider_type: Provider::GITHUB_PROVIDER), class: "btn btn-primary btn-sm" %>
|
||||
<%= link_to "+ Gitlab Credentials", new_provider_path(provider_type: Provider::GITLAB_PROVIDER), class: "btn btn-primary btn-sm" %>
|
||||
<%= link_to "+ Container Registry Credentials", new_provider_path(provider_type: Provider::CUSTOM_REGISTRY_PROVIDER), class: "btn btn-primary btn-sm" %>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-primary btn-sm">
|
||||
+ New Credential
|
||||
<iconify-icon icon="lucide:chevron-down"></iconify-icon>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-[1] w-60 p-2 shadow">
|
||||
<li>
|
||||
<%= link_to "Github", new_provider_path(provider_type: Provider::GITHUB_PROVIDER) %>
|
||||
<li>
|
||||
<%= link_to "Gitlab", new_provider_path(provider_type: Provider::GITLAB_PROVIDER) %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to "Container Registry", new_provider_path(provider_type: Provider::CUSTOM_REGISTRY_PROVIDER) %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% if current_account.stack_manager&.stack&.provides_registries? %>
|
||||
<%= button_to sync_registries_stack_manager_path, class: "btn btn-outline btn-sm" do %>
|
||||
<iconify-icon icon="lucide:refresh-ccw" height="16"></iconify-icon>
|
||||
@@ -18,8 +30,18 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="text-2xl font-bold">API Tokens</h2>
|
||||
<hr class="mt-3 mb-4 border-t border-base-300" />
|
||||
<%= turbo_frame_tag "api_token" do %>
|
||||
<%= render "api_tokens/index" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to "+ API Token", new_api_token_path, class: "btn btn-primary btn-sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if current_account.stack_manager&.portainer? && current_account.stack_manager.enable_role_based_access_control? %>
|
||||
<hr class="my-6 border-t border-base-300" />
|
||||
<%= render "providers/portainer_token" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
10
app/views/static/docs.html.erb
Normal file
10
app/views/static/docs.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Canine API Documentation</title>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url='<%= swagger_path %>'
|
||||
></redoc>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -58,6 +58,7 @@ Rails.application.configure do
|
||||
|
||||
# Raises error for missing translations.
|
||||
# config.i18n.raise_on_missing_translations = true
|
||||
config.hosts = nil
|
||||
|
||||
# Annotate rendered view with file names.
|
||||
# config.action_view.annotate_rendered_view_with_filenames = true
|
||||
|
||||
13
config/initializers/rswag_api.rb
Normal file
13
config/initializers/rswag_api.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
Rswag::Api.configure do |c|
|
||||
# Specify a root folder where Swagger JSON files are located
|
||||
# This is used by the Swagger middleware to serve requests for API descriptions
|
||||
# NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure
|
||||
# that it's configured to generate files in the same folder
|
||||
c.openapi_root = Rails.root.to_s + '/swagger'
|
||||
|
||||
# Inject a lambda function to alter the returned Swagger prior to serialization
|
||||
# The function will have access to the rack env for the current request
|
||||
# For example, you could leverage this to dynamically assign the "host" property
|
||||
#
|
||||
# c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] }
|
||||
end
|
||||
@@ -77,6 +77,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
resources :providers, only: %i[index new create destroy]
|
||||
resources :api_tokens, only: %i[index new create destroy]
|
||||
resource :portainer_token, only: %i[update destroy], controller: 'providers/portainer_tokens'
|
||||
resources :projects do
|
||||
member do
|
||||
@@ -154,6 +155,9 @@ Rails.application.routes.draw do
|
||||
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
||||
get "async_render" => "async_renderer#async_render"
|
||||
|
||||
get "/api-docs", to: "static#docs"
|
||||
get "/swagger", to: "static#swagger"
|
||||
|
||||
get "/calculator", to: "static#calculator"
|
||||
# Public marketing homepage
|
||||
if Rails.application.config.local_mode
|
||||
|
||||
13
db/migrate/20251022183720_create_api_tokens.rb
Normal file
13
db/migrate/20251022183720_create_api_tokens.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class CreateApiTokens < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :api_tokens do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :name, null: false
|
||||
t.string :access_token, null: false
|
||||
t.datetime :last_used_at
|
||||
t.datetime :expires_at
|
||||
t.timestamps
|
||||
end
|
||||
add_index :api_tokens, :access_token, unique: true
|
||||
end
|
||||
end
|
||||
12
db/schema.rb
generated
12
db/schema.rb
generated
@@ -86,6 +86,17 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_12_215414) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "api_tokens", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "access_token", null: false
|
||||
t.datetime "last_used_at"
|
||||
t.datetime "expires_at"
|
||||
t.jsonb "scopes", default: [], null: false, array: true
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_api_tokens_on_user_id"
|
||||
end
|
||||
|
||||
create_table "build_clouds", force: :cascade do |t|
|
||||
t.bigint "cluster_id", null: false
|
||||
t.string "namespace", default: "canine-k8s-builder", null: false
|
||||
@@ -608,6 +619,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_12_215414) do
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "add_ons", "clusters"
|
||||
add_foreign_key "api_tokens", "users"
|
||||
add_foreign_key "build_clouds", "clusters"
|
||||
add_foreign_key "build_configurations", "build_clouds"
|
||||
add_foreign_key "build_configurations", "projects"
|
||||
|
||||
29
spec/factories/api_tokens.rb
Normal file
29
spec/factories/api_tokens.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: api_tokens
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# access_token :string not null
|
||||
# expires_at :datetime
|
||||
# last_used_at :datetime
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_api_tokens_on_access_token (access_token) UNIQUE
|
||||
# index_api_tokens_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
FactoryBot.define do
|
||||
factory :api_token do
|
||||
user
|
||||
expires_at { nil }
|
||||
last_used_at { nil }
|
||||
end
|
||||
end
|
||||
44
spec/integration/api/models/project.rb
Normal file
44
spec/integration/api/models/project.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
SwaggerSchemas::PROJECT = {
|
||||
type: :object,
|
||||
required: %w[id name repository_url branch cluster_id subfolder updated_at created_at],
|
||||
properties: {
|
||||
id: {
|
||||
type: :integer,
|
||||
example: '1'
|
||||
},
|
||||
name: {
|
||||
type: :string,
|
||||
example: 'example-project'
|
||||
},
|
||||
repository_url: {
|
||||
type: :string,
|
||||
example: 'https://github.com/example/example-project'
|
||||
},
|
||||
branch: {
|
||||
type: :string,
|
||||
example: 'main'
|
||||
},
|
||||
cluster_id: {
|
||||
type: :integer,
|
||||
example: '1'
|
||||
},
|
||||
subfolder: {
|
||||
type: :string,
|
||||
example: 'example-subfolder'
|
||||
},
|
||||
updated_at: {
|
||||
type: :string,
|
||||
example: '2021-01-01T00:00:00Z'
|
||||
},
|
||||
created_at: {
|
||||
type: :string,
|
||||
example: '2021-01-01T00:00:00Z'
|
||||
},
|
||||
url: {
|
||||
type: :string,
|
||||
example: 'https://example.com/projects/1'
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
6
spec/integration/api/models/projects.rb
Normal file
6
spec/integration/api/models/projects.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
SwaggerSchemas::PROJECTS = {
|
||||
type: :array,
|
||||
items: {
|
||||
'$ref' => '#/components/schemas/project'
|
||||
}
|
||||
}.freeze
|
||||
19
spec/integration/api/swagger_schemas.rb
Normal file
19
spec/integration/api/swagger_schemas.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SwaggerSchemas
|
||||
def self.schemas
|
||||
constants.to_h do |constant|
|
||||
[ constant.to_s.downcase, const_get(constant) ]
|
||||
end
|
||||
end
|
||||
|
||||
def self.validate!
|
||||
schemas.each do |schema_name, schema|
|
||||
next unless const_defined?(schema_name.upcase)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Dir["#{File.dirname(__FILE__)}/models/**/*.rb"].each do |file|
|
||||
require file
|
||||
end
|
||||
10
spec/integration/api/swagger_schemas_spec.rb
Normal file
10
spec/integration/api/swagger_schemas_spec.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require './spec/integration/api/swagger_schemas'
|
||||
|
||||
RSpec.describe SwaggerSchemas do
|
||||
it 'matches' do
|
||||
expect { described_class.validate! }.not_to raise_error
|
||||
end
|
||||
end
|
||||
31
spec/integration/api/v1/deployments_spec.rb
Normal file
31
spec/integration/api/v1/deployments_spec.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe Projects::DeploymentsController, :swagger, type: :request do
|
||||
include ApplicationHelper
|
||||
let(:api_token) { create :api_token, user: }
|
||||
let(:'X-API-Key') { api_token.access_token }
|
||||
let(:account) { create :account }
|
||||
let(:user) { create :user }
|
||||
let!(:account_user) { create :account_user, account:, user: }
|
||||
let!(:cluster) { create :cluster, account: }
|
||||
let(:project) { create :project, :container_registry, cluster:, account: }
|
||||
|
||||
path '/projects/{project_id}/deployments/deploy' do
|
||||
let(:project_id) { project.id }
|
||||
|
||||
post('Deploy Project') do
|
||||
tags 'Deployments'
|
||||
operationId 'deployProject'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
parameter name: 'X-API-Key', in: :header, type: :string, description: 'API Key'
|
||||
parameter name: :project_id, in: :path, type: :integer, description: 'Project ID'
|
||||
|
||||
response(200, 'successful') do
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
46
spec/integration/api/v1/projects_spec.rb
Normal file
46
spec/integration/api/v1/projects_spec.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe ProjectsController, :swagger, type: :request do
|
||||
include ApplicationHelper
|
||||
let(:api_token) { create :api_token }
|
||||
let(:'X-API-Key') { api_token.access_token }
|
||||
let(:account) { create :account }
|
||||
let(:project) { create :project, account: }
|
||||
before do
|
||||
api_token.user.accounts << account
|
||||
end
|
||||
path '/projects' do
|
||||
get('List Projects') do
|
||||
tags 'Projects'
|
||||
operationId 'listProjects'
|
||||
|
||||
produces 'application/json'
|
||||
parameter name: 'X-API-Key', in: :header, type: :string, description: 'API Key'
|
||||
|
||||
response(200, 'successful') do
|
||||
schema '$ref' => '#/components/schemas/projects'
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/projects/{id}/restart' do
|
||||
let(:id) { project.id }
|
||||
post('Restart Project') do
|
||||
tags 'Projects'
|
||||
operationId 'restartProject'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
security [ x_api_key: [] ]
|
||||
parameter name: :id, in: :path, type: :integer, description: 'Project ID'
|
||||
parameter name: 'X-API-Key', in: :header, type: :string, description: 'API Key'
|
||||
|
||||
|
||||
response(200, 'successful') do
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
59
spec/models/api_token_spec.rb
Normal file
59
spec/models/api_token_spec.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: api_tokens
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# access_token :string not null
|
||||
# expires_at :datetime
|
||||
# last_used_at :datetime
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_api_tokens_on_access_token (access_token) UNIQUE
|
||||
# index_api_tokens_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ApiToken, type: :model do
|
||||
context "#generate_token" do
|
||||
it "generates a random access token on create" do
|
||||
api_token = create(:api_token)
|
||||
expect(api_token.access_token).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "#expired?" do
|
||||
let(:api_token) { create(:api_token, expires_at:) }
|
||||
let(:expires_at) { nil }
|
||||
|
||||
context "when expires_at is nil" do
|
||||
it "returns false" do
|
||||
expect(api_token.expired?).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context "when expires_at is in the future" do
|
||||
let(:expires_at) { 1.day.from_now }
|
||||
|
||||
it "returns false" do
|
||||
expect(api_token.expired?).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context "when expires_at is in the past" do
|
||||
let(:expires_at) { 1.day.ago }
|
||||
|
||||
it "returns true" do
|
||||
expect(api_token.expired?).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -60,6 +60,8 @@ RSpec.configure do |config|
|
||||
# instead of true.
|
||||
config.use_transactional_fixtures = false
|
||||
|
||||
|
||||
config.rswag_dry_run = false
|
||||
# You can uncomment this line to turn off ActiveRecord support entirely.
|
||||
# config.use_active_record = false
|
||||
|
||||
|
||||
57
spec/swagger_helper.rb
Normal file
57
spec/swagger_helper.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require 'integration/api/swagger_schemas'
|
||||
|
||||
servers = [
|
||||
{
|
||||
url: 'https://canine.sh',
|
||||
description: 'Production'
|
||||
}
|
||||
]
|
||||
|
||||
RSpec.configure do |config|
|
||||
# Specify a root folder where Swagger JSON files are generated
|
||||
# NOTE: If you're using the rswag-api to serve API descriptions, you'll need
|
||||
# to ensure that it's configured to serve Swagger from the same folder
|
||||
config.openapi_root = Rails.root.join('swagger').to_s
|
||||
|
||||
# Define one or more Swagger documents and provide global metadata for each one
|
||||
# When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will
|
||||
# be generated at the provided relative path under swagger_root
|
||||
# By default, the operations defined in spec files are added to the first
|
||||
# document below. You can override this behavior by adding a swagger_doc tag to the
|
||||
# the root example_group in your specs, e.g. describe '...', swagger_doc: 'v2/swagger.json'
|
||||
config.openapi_specs = {
|
||||
'v1/swagger.yaml' => {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Canine API V1',
|
||||
version: 'v1',
|
||||
description: "Canine API"
|
||||
},
|
||||
components: {
|
||||
schemas: SwaggerSchemas.schemas,
|
||||
securitySchemes: {
|
||||
x_api_key: {
|
||||
type: :apiKey,
|
||||
name: 'X-API-Key',
|
||||
in: :header
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{ x_api_key: [] }
|
||||
],
|
||||
paths: {},
|
||||
servers:
|
||||
}
|
||||
}
|
||||
|
||||
# Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'.
|
||||
# The swagger_docs configuration option has the filename including format in
|
||||
# the key, this may want to be changed to avoid putting yaml in json files.
|
||||
# Defaults to json. Accepts ':json' and ':yaml'.
|
||||
config.openapi_format = :yaml
|
||||
config.swagger_strict_schema_validation = true
|
||||
end
|
||||
125
swagger/v1/swagger.yaml
Normal file
125
swagger/v1/swagger.yaml
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
openapi: 3.0.1
|
||||
info:
|
||||
title: Canine API V1
|
||||
version: v1
|
||||
description: Canine API
|
||||
components:
|
||||
schemas:
|
||||
project:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- repository_url
|
||||
- branch
|
||||
- cluster_id
|
||||
- subfolder
|
||||
- updated_at
|
||||
- created_at
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: '1'
|
||||
name:
|
||||
type: string
|
||||
example: example-project
|
||||
repository_url:
|
||||
type: string
|
||||
example: https://github.com/example/example-project
|
||||
branch:
|
||||
type: string
|
||||
example: main
|
||||
cluster_id:
|
||||
type: integer
|
||||
example: '1'
|
||||
subfolder:
|
||||
type: string
|
||||
example: example-subfolder
|
||||
updated_at:
|
||||
type: string
|
||||
example: '2021-01-01T00:00:00Z'
|
||||
created_at:
|
||||
type: string
|
||||
example: '2021-01-01T00:00:00Z'
|
||||
url:
|
||||
type: string
|
||||
example: https://example.com/projects/1
|
||||
projects:
|
||||
type: array
|
||||
items:
|
||||
"$ref": "#/components/schemas/project"
|
||||
securitySchemes:
|
||||
x_api_key:
|
||||
type: apiKey
|
||||
name: X-API-Key
|
||||
in: header
|
||||
security:
|
||||
- x_api_key: []
|
||||
paths:
|
||||
"/projects/{project_id}/deployments/deploy":
|
||||
post:
|
||||
summary: Deploy Project
|
||||
tags:
|
||||
- Deployments
|
||||
operationId: deployProject
|
||||
parameters:
|
||||
- name: X-API-Key
|
||||
in: header
|
||||
description: API Key
|
||||
schema:
|
||||
type: string
|
||||
- name: project_id
|
||||
in: path
|
||||
description: Project ID
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: successful
|
||||
"/projects":
|
||||
get:
|
||||
summary: List Projects
|
||||
tags:
|
||||
- Projects
|
||||
operationId: listProjects
|
||||
parameters:
|
||||
- name: X-API-Key
|
||||
in: header
|
||||
description: API Key
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/projects"
|
||||
"/projects/{id}/restart":
|
||||
post:
|
||||
summary: Restart Project
|
||||
tags:
|
||||
- Projects
|
||||
operationId: restartProject
|
||||
security:
|
||||
- x_api_key: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: Project ID
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: X-API-Key
|
||||
in: header
|
||||
description: API Key
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: successful
|
||||
servers:
|
||||
- url: https://canine.sh
|
||||
description: Production
|
||||
Reference in New Issue
Block a user