Test single api token
This commit is contained in:
parent
495746d6a1
commit
ec2f6b9d52
@ -1,4 +1,3 @@
|
|||||||
use actix_jwt_session::Authenticated;
|
|
||||||
use actix_web::web::{Data, Path, ServiceConfig};
|
use actix_web::web::{Data, Path, ServiceConfig};
|
||||||
use actix_web::{get, HttpResponse};
|
use actix_web::{get, HttpResponse};
|
||||||
use entities::api_tokens::*;
|
use entities::api_tokens::*;
|
||||||
@ -9,9 +8,8 @@ use sea_orm::*;
|
|||||||
use squadron_contract::{ApiTokenId, WorkspaceSlug};
|
use squadron_contract::{ApiTokenId, WorkspaceSlug};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::extractors::RequireInstanceConfigured;
|
use crate::extractors::{RequireInstanceConfigured, RequireUser};
|
||||||
use crate::models::{Error, JsonError};
|
use crate::models::{Error, JsonError};
|
||||||
use crate::session::AppClaims;
|
|
||||||
use crate::DatabaseConnection;
|
use crate::DatabaseConnection;
|
||||||
|
|
||||||
pub fn configure(_: reqwest::Client, config: &mut ServiceConfig) {
|
pub fn configure(_: reqwest::Client, config: &mut ServiceConfig) {
|
||||||
@ -21,19 +19,11 @@ pub fn configure(_: reqwest::Client, config: &mut ServiceConfig) {
|
|||||||
#[get("{workspace_slug}/api-tokens/{id}")]
|
#[get("{workspace_slug}/api-tokens/{id}")]
|
||||||
async fn single_api_token(
|
async fn single_api_token(
|
||||||
_: RequireInstanceConfigured,
|
_: RequireInstanceConfigured,
|
||||||
session: Authenticated<AppClaims>,
|
user: RequireUser,
|
||||||
path: Path<(WorkspaceSlug, ApiTokenId)>,
|
path: Path<(WorkspaceSlug, ApiTokenId)>,
|
||||||
db: Data<DatabaseConnection>,
|
db: Data<DatabaseConnection>,
|
||||||
) -> Result<HttpResponse, JsonError> {
|
) -> Result<HttpResponse, JsonError> {
|
||||||
let (slug, id) = path.into_inner();
|
let (slug, id) = path.into_inner();
|
||||||
let user = entities::prelude::Users::find_by_id(session.account_id())
|
|
||||||
.one(&**db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Failed to load user: {e}");
|
|
||||||
Error::DatabaseError
|
|
||||||
})?
|
|
||||||
.ok_or(Error::UserRequired)?;
|
|
||||||
|
|
||||||
let slug = slug.as_str().to_string();
|
let slug = slug.as_str().to_string();
|
||||||
match ApiTokens::find()
|
match ApiTokens::find()
|
||||||
@ -65,4 +55,249 @@ async fn single_api_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod single_tests {}
|
mod single_tests {
|
||||||
|
use actix_jwt_session::{
|
||||||
|
Hashing, JwtTtl, RefreshTtl, SessionMiddlewareFactory, JWT_COOKIE_NAME, JWT_HEADER_NAME,
|
||||||
|
REFRESH_COOKIE_NAME, REFRESH_HEADER_NAME,
|
||||||
|
};
|
||||||
|
use actix_web::body::to_bytes;
|
||||||
|
use actix_web::web::Data;
|
||||||
|
use actix_web::{test, App};
|
||||||
|
use reqwest::{Method, StatusCode};
|
||||||
|
use sea_orm::Database;
|
||||||
|
use squadron_contract::deadpool_redis;
|
||||||
|
use tracing_test::traced_test;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::super::*;
|
||||||
|
use super::*;
|
||||||
|
use crate::session;
|
||||||
|
use crate::utils::{slugify, ApiTokenBuilder, UserExt};
|
||||||
|
|
||||||
|
macro_rules! create_app {
|
||||||
|
($app: ident, $session_storage: ident, $db: ident) => {
|
||||||
|
std::env::set_var(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgres://postgres@0.0.0.0:5432/squadron_test",
|
||||||
|
);
|
||||||
|
let redis = deadpool_redis::Config::from_url("redis://0.0.0.0:6379")
|
||||||
|
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
||||||
|
.expect("Can't connect to redis");
|
||||||
|
let $db: sea_orm::prelude::DatabaseConnection =
|
||||||
|
Database::connect("postgres://postgres@0.0.0.0:5432/squadron_test")
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
let ($session_storage, factory) =
|
||||||
|
SessionMiddlewareFactory::<session::AppClaims>::build_ed_dsa()
|
||||||
|
.with_redis_pool(redis.clone())
|
||||||
|
// Check if header "Authorization" exists and contains Bearer with encoded JWT
|
||||||
|
.with_jwt_header(JWT_HEADER_NAME)
|
||||||
|
// Check if cookie JWT exists and contains encoded JWT
|
||||||
|
.with_jwt_cookie(JWT_COOKIE_NAME)
|
||||||
|
.with_refresh_header(REFRESH_HEADER_NAME)
|
||||||
|
// Check if cookie JWT exists and contains encoded JWT
|
||||||
|
.with_refresh_cookie(REFRESH_COOKIE_NAME)
|
||||||
|
.with_jwt_json(&["access_token"])
|
||||||
|
.finish();
|
||||||
|
let $db = Data::new($db.clone());
|
||||||
|
ensure_instance($db.clone()).await;
|
||||||
|
let $app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(Data::new($session_storage.clone()))
|
||||||
|
.app_data($db.clone())
|
||||||
|
.app_data(Data::new(redis))
|
||||||
|
.wrap(actix_web::middleware::NormalizePath::trim())
|
||||||
|
.wrap(actix_web::middleware::Logger::default())
|
||||||
|
.wrap(factory)
|
||||||
|
.service(single_api_token),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_instance(db: Data<DatabaseConnection>) {
|
||||||
|
use entities::instances::*;
|
||||||
|
use entities::prelude::Instances;
|
||||||
|
use sea_orm::*;
|
||||||
|
|
||||||
|
if Instances::find().count(&**db).await.unwrap() > 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ActiveModel {
|
||||||
|
instance_name: Set("Plan Free".into()),
|
||||||
|
is_telemetry_enabled: Set(true),
|
||||||
|
is_support_required: Set(true),
|
||||||
|
is_setup_done: Set(true),
|
||||||
|
is_signup_screen_visited: Set(true),
|
||||||
|
is_verified: Set(true),
|
||||||
|
user_count: Set(0),
|
||||||
|
instance_id: Set(random_hex(12)),
|
||||||
|
api_key: Set(random_hex(8)),
|
||||||
|
version: Set(env!("CARGO_PKG_VERSION").to_string()),
|
||||||
|
last_checked_at: Set(chrono::Utc::now().fixed_offset()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.save(&**db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(
|
||||||
|
db: Data<DatabaseConnection>,
|
||||||
|
user_name: &str,
|
||||||
|
pass: &str,
|
||||||
|
) -> entities::users::Model {
|
||||||
|
use entities::users::*;
|
||||||
|
use sea_orm::*;
|
||||||
|
|
||||||
|
if let Ok(Some(user)) = Users::find()
|
||||||
|
.filter(Column::Email.eq(format!("{user_name}@example.com")))
|
||||||
|
.one(&**db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pass = Hashing::encrypt(pass).unwrap();
|
||||||
|
|
||||||
|
Users::insert(ActiveModel {
|
||||||
|
password: Set(pass),
|
||||||
|
email: Set(Some(format!("{user_name}@example.com"))),
|
||||||
|
display_name: Set(user_name.to_string()),
|
||||||
|
username: Set(Uuid::new_v4().to_string()),
|
||||||
|
first_name: Set("".to_string()),
|
||||||
|
last_name: Set("".to_string()),
|
||||||
|
last_location: Set("".to_string()),
|
||||||
|
created_location: Set("".to_string()),
|
||||||
|
is_password_autoset: Set(false),
|
||||||
|
token: Set(Uuid::new_v4().to_string()),
|
||||||
|
billing_address_country: Set("".to_string()),
|
||||||
|
user_timezone: Set("UTC".to_string()),
|
||||||
|
last_login_ip: Set("0.0.0.0".to_string()),
|
||||||
|
last_login_medium: Set("None".to_string()),
|
||||||
|
last_logout_ip: Set("0.0.0.0".to_string()),
|
||||||
|
last_login_uagent: Set("test".to_string()),
|
||||||
|
is_active: Set(true),
|
||||||
|
avatar: Set("".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec_with_returning(&**db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_workspace(
|
||||||
|
db: Data<DatabaseConnection>,
|
||||||
|
name: &str,
|
||||||
|
user: &entities::users::Model,
|
||||||
|
) -> entities::workspaces::Model {
|
||||||
|
use entities::prelude::*;
|
||||||
|
use entities::workspaces::*;
|
||||||
|
|
||||||
|
let slug = slugify(name);
|
||||||
|
if let Ok(Some(m)) = Workspaces::find()
|
||||||
|
.filter(Column::Slug.eq(slug))
|
||||||
|
.one(&**db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
Workspaces::insert(user.new_workspace(name))
|
||||||
|
.exec_with_returning(&**db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_api_token(
|
||||||
|
db: Data<DatabaseConnection>,
|
||||||
|
label: &str,
|
||||||
|
desc: &str,
|
||||||
|
user: &entities::users::Model,
|
||||||
|
workspace: &entities::workspaces::Model,
|
||||||
|
) -> entities::api_tokens::Model {
|
||||||
|
use entities::api_tokens::*;
|
||||||
|
use entities::prelude::*;
|
||||||
|
|
||||||
|
if let Ok(Some(m)) = ApiTokens::find()
|
||||||
|
.filter(
|
||||||
|
Column::Label.eq(label).and(
|
||||||
|
Column::Description.eq(desc).and(
|
||||||
|
Column::WorkspaceId
|
||||||
|
.eq(workspace.id)
|
||||||
|
.and(Column::UserId.eq(user.id)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.one(&**db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiTokens::insert(
|
||||||
|
ApiTokenBuilder::new(label, desc, user, Some(workspace)).into_active_model(),
|
||||||
|
)
|
||||||
|
.exec_with_returning(&**db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[traced_test]
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn valid() {
|
||||||
|
create_app!(app, session, db);
|
||||||
|
let user = create_user(db.clone(), "valid_single_api_token", "qweQWE123!@#").await;
|
||||||
|
let workspace = ensure_workspace(db.clone(), "Foo bar%&^", &user).await;
|
||||||
|
let api_token = ensure_api_token(
|
||||||
|
db.clone(),
|
||||||
|
"valid search single api token",
|
||||||
|
"",
|
||||||
|
&user,
|
||||||
|
&workspace,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let uri = format!(
|
||||||
|
"http://example.com/{}/api-tokens/{}",
|
||||||
|
workspace.slug, api_token.id
|
||||||
|
);
|
||||||
|
eprintln!("URI: {uri:?}");
|
||||||
|
|
||||||
|
let pair = session
|
||||||
|
.store(
|
||||||
|
AppClaims {
|
||||||
|
expiration_time: (chrono::Utc::now() + chrono::Duration::days(100))
|
||||||
|
.timestamp_millis(),
|
||||||
|
issued_at: 0,
|
||||||
|
subject: "999999999".into(),
|
||||||
|
audience: session::Audience::Web,
|
||||||
|
jwt_id: Uuid::new_v4(),
|
||||||
|
account_id: user.id,
|
||||||
|
not_before: 0,
|
||||||
|
},
|
||||||
|
JwtTtl::new(actix_jwt_session::Duration::days(9999)),
|
||||||
|
RefreshTtl::new(actix_jwt_session::Duration::days(9999)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let req = test::TestRequest::default()
|
||||||
|
.insert_header((JWT_HEADER_NAME, pair.jwt.encode().unwrap()))
|
||||||
|
.uri(&uri)
|
||||||
|
.method(Method::GET)
|
||||||
|
.to_request();
|
||||||
|
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = resp.into_body();
|
||||||
|
let json: serde_json::Value =
|
||||||
|
serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap();
|
||||||
|
|
||||||
|
let expected: serde_json::Value =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&api_token).unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json, expected,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -156,7 +156,9 @@ mod tests {
|
|||||||
.app_data($db.clone())
|
.app_data($db.clone())
|
||||||
.app_data(Data::new(redis))
|
.app_data(Data::new(redis))
|
||||||
.app_data(Data::new(EventBusClient::new_succ_mock()))
|
.app_data(Data::new(EventBusClient::new_succ_mock()))
|
||||||
.app_data(Data::new(PasswordResetSecret::new("ahsdhy9asd".to_string())))
|
.app_data(Data::new(PasswordResetSecret::new(
|
||||||
|
"ahsdhy9asd".to_string(),
|
||||||
|
)))
|
||||||
.wrap(actix_web::middleware::NormalizePath::trim())
|
.wrap(actix_web::middleware::NormalizePath::trim())
|
||||||
.wrap(actix_web::middleware::Logger::default())
|
.wrap(actix_web::middleware::Logger::default())
|
||||||
.wrap(factory)
|
.wrap(factory)
|
||||||
|
@ -19,7 +19,7 @@ use sea_orm::*;
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::http::{AuthError, OAuthError};
|
use crate::http::{random_hex, AuthError, OAuthError};
|
||||||
use crate::models::Error;
|
use crate::models::Error;
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
@ -392,3 +392,117 @@ pub mod pass_reset_token {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait UserExt {
|
||||||
|
fn new_workspace(&self, name: &str) -> entities::workspaces::ActiveModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserExt for entities::users::Model {
|
||||||
|
fn new_workspace(&self, name: &str) -> entities::workspaces::ActiveModel {
|
||||||
|
entities::workspaces::ActiveModel::new_workspace(self.id, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait WorkspaceExt {
|
||||||
|
fn new_workspace(owner_id: Uuid, name: &str) -> entities::workspaces::ActiveModel {
|
||||||
|
use sea_orm::*;
|
||||||
|
|
||||||
|
entities::workspaces::ActiveModel {
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
slug: Set(slugify(name)),
|
||||||
|
owner_id: Set(owner_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceExt for entities::workspaces::ActiveModel {}
|
||||||
|
|
||||||
|
pub struct ApiTokenBuilder {
|
||||||
|
pub created_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||||
|
pub updated_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||||
|
|
||||||
|
pub token: String,
|
||||||
|
pub label: String,
|
||||||
|
pub user_type: i16,
|
||||||
|
pub created_by_id: Option<Uuid>,
|
||||||
|
pub updated_by_id: Option<Uuid>,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub workspace_id: Option<Uuid>,
|
||||||
|
pub description: String,
|
||||||
|
pub expired_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub last_used: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiTokenBuilder {
|
||||||
|
pub fn new(
|
||||||
|
label: &str,
|
||||||
|
desc: &str,
|
||||||
|
user: &entities::users::Model,
|
||||||
|
workspace: Option<&entities::workspaces::Model>,
|
||||||
|
) -> Self {
|
||||||
|
let now = chrono::Utc::now().fixed_offset();
|
||||||
|
Self {
|
||||||
|
token: random_hex(60),
|
||||||
|
created_at: Some(now),
|
||||||
|
updated_at: Some(now),
|
||||||
|
label: label.to_string(),
|
||||||
|
user_type: if user.is_bot { 1 } else { 0 },
|
||||||
|
created_by_id: Some(user.id),
|
||||||
|
updated_by_id: Some(user.id),
|
||||||
|
user_id: user.id,
|
||||||
|
workspace_id: workspace.map(|w| w.id),
|
||||||
|
description: desc.to_string(),
|
||||||
|
expired_at: None,
|
||||||
|
is_active: true,
|
||||||
|
last_used: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_active_model(self) -> entities::api_tokens::ActiveModel {
|
||||||
|
use sea_orm::*;
|
||||||
|
|
||||||
|
let ApiTokenBuilder {
|
||||||
|
label,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
token,
|
||||||
|
user_type,
|
||||||
|
created_by_id,
|
||||||
|
updated_by_id,
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
description,
|
||||||
|
expired_at,
|
||||||
|
is_active,
|
||||||
|
last_used,
|
||||||
|
} = self;
|
||||||
|
entities::api_tokens::ActiveModel {
|
||||||
|
label: Set(label),
|
||||||
|
created_at: created_at.map(Set).unwrap_or(NotSet),
|
||||||
|
updated_at: updated_at.map(Set).unwrap_or(NotSet),
|
||||||
|
token: Set(token),
|
||||||
|
user_type: Set(user_type),
|
||||||
|
created_by_id: Set(created_by_id),
|
||||||
|
updated_by_id: Set(updated_by_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
workspace_id: Set(workspace_id),
|
||||||
|
description: Set(description),
|
||||||
|
expired_at: Set(expired_at),
|
||||||
|
is_active: Set(is_active),
|
||||||
|
last_used: Set(last_used),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slugify(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
_ if c.is_whitespace() => Some('-'),
|
||||||
|
_ if c.is_ascii_alphanumeric() => Some(c.to_ascii_lowercase()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
@ -91,10 +91,6 @@ CREATE TABLE users (
|
|||||||
use_case text
|
use_case text
|
||||||
);
|
);
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: workspaces; Type: TABLE; Schema: public; Owner: plane
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE workspaces (
|
CREATE TABLE workspaces (
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
6
web/architecture.svg
Normal file
6
web/architecture.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg height="600" width="600" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g>
|
||||||
|
<rect x="20" y="20" width="100" height="60" fill="rgb(94, 235, 244)" />
|
||||||
|
<text x="20" y="100">API Server</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 217 B |
24
web/index.html
Normal file
24
web/index.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<base href="/" />
|
||||||
|
<main>
|
||||||
|
<header style="display:flex;position:sticky;top:0;left:0;height:2rem;width:100%;">
|
||||||
|
<div style="display:flex;">
|
||||||
|
<picture>
|
||||||
|
<img src="/logo.svg" style="height:36px;margin-right:1rem;" />
|
||||||
|
</picture>
|
||||||
|
<b style="height:2rem;line-height:2rem;text-align:center;display:block;">Squadron</b>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<app style="display:block;position:relative;margin-top:2rem;">
|
||||||
|
<section>
|
||||||
|
<img src="/architecture.svg" />
|
||||||
|
</section>
|
||||||
|
</app>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
18
web/logo.svg
Normal file
18
web/logo.svg
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<svg height="560" width="560" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<g id="jet">
|
||||||
|
<polygon points="200,60 260,150 140,150" />
|
||||||
|
<polygon points="200,10 225,190 175,190" />
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
<circle r="260" cx="280" cy="280" fill="rgb(94, 235, 244)" />
|
||||||
|
<g transform="translate(0 90) rotate(-45 0 0)" transform-box="fill-box" transform-origin="center" style="fill:#00569d;stroke:none;stroke-width:0">
|
||||||
|
<g transform="translate(30, 30)"><use href="#jet" /></g>
|
||||||
|
<g transform="translate(130, -90)"><use href="#jet" /></g>
|
||||||
|
<g transform="translate(230, 30)"><use href="#jet" /></g>
|
||||||
|
|
||||||
|
<g transform="translate(130, 190)"><use href="#jet" /></g>
|
||||||
|
<g transform="translate(300, 190)"><use href="#jet" /></g>
|
||||||
|
<g transform="translate(-50, 190)"><use href="#jet" /></g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 785 B |
Loading…
Reference in New Issue
Block a user