Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b34f23b43 | ||
|
|
7a88118349 | ||
|
|
e07bf35cd0 | ||
|
|
a1147a98d2 | ||
|
|
f67fb7db31 | ||
|
|
7d4e89ced9 | ||
|
|
b9ee1bd225 | ||
|
|
68886b62fe | ||
|
|
35a859529f | ||
|
|
3a93e358cd | ||
|
|
1cb0b32898 | ||
|
|
c50bb78c32 |
No files matched your search
@@ -35,6 +35,7 @@ artcraft-services/
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
|
||||
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
|
||||
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (Vite)
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
└── Cargo.toml # Rust workspace
|
||||
```
|
||||
|
||||
Generated
+9
@@ -313,6 +313,7 @@ dependencies = [
|
||||
"actix-http",
|
||||
"actix-rt",
|
||||
"actix-web",
|
||||
"domains",
|
||||
"log",
|
||||
"server_environment",
|
||||
"speculoos",
|
||||
@@ -2688,6 +2689,13 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "domains"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
@@ -8715,6 +8723,7 @@ dependencies = [
|
||||
"datetimes",
|
||||
"derive_more 0.99.17",
|
||||
"diesel",
|
||||
"domains",
|
||||
"easyenv",
|
||||
"elasticsearch",
|
||||
"elasticsearch_schema",
|
||||
|
||||
@@ -67,6 +67,7 @@ members = [
|
||||
"crates/lib/files/zip_archives",
|
||||
"crates/lib/http_headers",
|
||||
"crates/lib/jobs_common",
|
||||
"crates/lib/domains",
|
||||
"crates/lib/jwt_light",
|
||||
"crates/lib/opaque_cursors",
|
||||
"crates/lib/jwt_signer",
|
||||
@@ -252,6 +253,7 @@ grok_api_client = { path = "crates/api_clients/grok_api_client" }
|
||||
rootly_client = { path = "crates/api_clients/rootly_client" }
|
||||
rootly_config = { path = "crates/schema/config/rootly_config" }
|
||||
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
|
||||
domains = { path = "crates/lib/domains" }
|
||||
http_headers = { path = "crates/lib/http_headers" }
|
||||
images = { path = "crates/lib/files/images" }
|
||||
jobs_common = { path = "crates/lib/jobs_common" }
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::responses::cover_image_links::CoverImageLinks;
|
||||
use crate::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
|
||||
|
||||
// User-media list endpoints retain the deprecated bucket fields for compatibility.
|
||||
/// Everything we need to create a cover image.
|
||||
/// Cover images are small descriptive images that can be set for any media file.
|
||||
/// If a cover image is set, this is the path to the asset.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct LegacyMediaFileCoverImageDetails {
|
||||
/// (DEPRECATED) URL path to the media file
|
||||
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
|
||||
pub maybe_cover_image_public_bucket_path: Option<String>,
|
||||
|
||||
/// (DEPRECATED) Full URL to the media file
|
||||
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
|
||||
pub maybe_cover_image_public_bucket_url: Option<Url>,
|
||||
|
||||
/// Links to the cover image (CDN direct link, thumbnail template)
|
||||
/// If a cover image is set, this is the path to the asset.
|
||||
/// If a cover image is not set, use the information in `default_cover` instead.
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub maybe_links: Option<CoverImageLinks>,
|
||||
|
||||
/// For items without a cover image, we can use one of our own.
|
||||
pub default_cover: MediaFileDefaultCover,
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use tokens::tokens::model_weights::ModelWeightToken;
|
||||
|
||||
/// Fields useful for enriching media file listings
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileOriginDetails {
|
||||
/// Where the file came from (broadly)
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
/// Where the file came from (specifically, a product area, eg. "face_animator")
|
||||
pub product_category: MediaFileOriginProductCategory,
|
||||
|
||||
/// If the file was produced by a model, the details.
|
||||
pub maybe_model: Option<MediaFileModelDetails>,
|
||||
}
|
||||
|
||||
/// Information about the model weights
|
||||
/// https://serde.rs/enum-representations.html#untagged
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum MediaFileModelDetails {
|
||||
// Try the specific shape first so deserialization retains token and title.
|
||||
/// A model weight is typically a user-submitted model.
|
||||
/// We store lots of these.
|
||||
ModelWeight {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
/// The model token
|
||||
token: ModelWeightToken,
|
||||
/// The model title (typically only populated for `model_weights` models, not legacy tables such as `tts_models`.)
|
||||
title: String,
|
||||
},
|
||||
/// A system model is one that we maintain and update,
|
||||
/// eg. SadTalker, Wav2Lip, MocapNet, etc.
|
||||
SystemModel {
|
||||
/// The type of model weight
|
||||
model_type: MediaFileOriginModelType,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod cover_image_links;
|
||||
pub mod job_details;
|
||||
pub mod legacy_media_file_cover_image_details;
|
||||
pub mod media_file_cover_image_details;
|
||||
pub mod media_file_origin_details;
|
||||
pub mod media_links;
|
||||
pub mod pagination_cursors;
|
||||
pub mod simple_entity_stats;
|
||||
pub mod simple_generic_json_success;
|
||||
pub mod user_details_light;
|
||||
pub mod user_details_light;
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// TODO(bt,2025-08-20): Replace the storyteller-web version of this.
|
||||
|
||||
/// Simple stats that can be attached to any entity
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct SimpleEntityStats {
|
||||
/// Number of positive ratings (or "likes") for this item
|
||||
pub positive_rating_count: u32,
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
|
||||
pub const LIST_MEDIA_FILES_FOR_USER_V2_URL_PATH: &str = "/v1/media_files/list_v2/user/{username}";
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2PathInfo {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMediaFilesForUserV2QueryParams {
|
||||
pub sort_ascending: Option<bool>,
|
||||
pub page_size: Option<usize>,
|
||||
pub page_index: Option<usize>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileClass`,
|
||||
/// which are the broad category of media files: image, video, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_classes=audio`
|
||||
/// - `?filter_media_classes=image,video`
|
||||
/// - `?filter_media_classes=dimensional`
|
||||
/// - etc.
|
||||
pub filter_media_classes: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileType`,
|
||||
/// which are mimetype-like / format-like categories of media files: glb, gltf,
|
||||
/// scene_json, jpg, png, mp4, wav, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_type=scene_json`
|
||||
/// - `?filter_media_type=glb,gltf,fbx`
|
||||
/// - `?filter_media_type=pmd,vmd,pmx`
|
||||
/// - `?filter_media_type=jpg,png,gif`
|
||||
/// - `?filter_media_type=wav,mp3`
|
||||
/// - `?filter_media_type=mp4`
|
||||
/// - etc.
|
||||
pub filter_media_type: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileEngineCategory`.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_engine_categories=scene`
|
||||
/// - `?filter_engine_categories=animation,character,object`
|
||||
/// - etc.
|
||||
pub filter_engine_categories: Option<String>,
|
||||
|
||||
/// Include user uploaded files in the results.
|
||||
/// By default, we do not return them unless this flag is set to true.
|
||||
pub include_user_uploads: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2SuccessResponse {
|
||||
pub success: bool,
|
||||
pub results: Vec<MediaFileForUserListItem>,
|
||||
pub pagination: UserMediaFileV2Pagination,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserMediaFileV2Pagination {
|
||||
pub current: usize,
|
||||
pub has_more: bool,
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pub mod by_type;
|
||||
pub mod list_media_files_for_user_v2;
|
||||
pub mod user_media_file_list_item;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::common::visibility::Visibility;
|
||||
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use crate::common::responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::common::responses::media_links::MediaLinks;
|
||||
use crate::common::responses::simple_entity_stats::SimpleEntityStats;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct MediaFileForUserListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
/// The coarse-grained class of media file: image, video, etc.
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
/// Type of media will dictate which fields are populated and what
|
||||
/// the frontend should display (eg. video player vs audio player).
|
||||
/// This is closer in meaning to a "mime type".
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
/// If this is an engine/3D asset, this is the broad category (scene,
|
||||
/// animation, etc.) of that object.
|
||||
/// This can also be used for filtering in list/batch endpoints.
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
|
||||
/// If this is an engine/3D asset for an animation or a rig that can
|
||||
/// be animated with either (or both) skeletal or blend shape animations,
|
||||
/// this describes the animation regime used or supported.
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
/// Details where the media file came from.
|
||||
pub origin: MediaFileOriginDetails,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
/// (DEPRECATED) URL path to the media file
|
||||
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_path: String,
|
||||
|
||||
/// (DEPRECATED) Full URL to the media file
|
||||
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_url: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// The foreign key to the prompt used to generate the media, if applicable.
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// Information about the cover image. Many media files do not require a cover image,
|
||||
/// e.g. image files, video files with thumbnails, audio files, etc.
|
||||
/// 3D files require them.
|
||||
pub cover_image: LegacyMediaFileCoverImageDetails,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
/// The file was uploaded by the user.
|
||||
/// This does not include files generated on the client side, like studio renders.
|
||||
pub is_user_upload: bool,
|
||||
|
||||
/// The file was created by the system.
|
||||
/// This includes files generated on the client side, like studio renders.
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
/// The name or title of the media file (optional)
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
|
||||
/// For Comfy / Video Style Transfer jobs, this might include
|
||||
/// the name of the selected style.
|
||||
pub maybe_style_name: Option<StyleTransferName>,
|
||||
|
||||
/// Duration for audio and video files, if available.
|
||||
/// Measured in milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
/// Statistics about the media file
|
||||
pub stats: SimpleEntityStats,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -20,6 +20,7 @@ normal = ["workspace-build-acceleration"]
|
||||
workspace-build-acceleration = { workspace = true }
|
||||
|
||||
# Internal
|
||||
domains.workspace = true
|
||||
server_environment.workspace = true
|
||||
|
||||
# External
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Regression tests pinning every exact origin the CORS config allowed before
|
||||
//! first-party domains moved to the `domains` crate (commit a1147a98d2).
|
||||
//!
|
||||
//! The origins are string literals on purpose: they check the `domains`
|
||||
//! constants against the real values instead of against themselves. Each row
|
||||
//! is checked against its module in isolation (so a module can't pass because
|
||||
//! another module allows the same origin) and against the full config.
|
||||
//!
|
||||
//! The table was generated from the `allowed_origin("...")` calls at
|
||||
//! a1147a98d2. When you add or remove an origin, update this table too.
|
||||
|
||||
use actix_cors::Cors;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::configs::artcraft_2d::add_artcraft_2d;
|
||||
use crate::configs::artcraft_3d::add_artcraft_3d;
|
||||
use crate::configs::artcraft_admin_dashboard::add_artcraft_admin_dashboard;
|
||||
use crate::configs::artcraft_desktop::add_artcraft_desktop;
|
||||
use crate::configs::artcraft_webapp::add_artcraft_webapp;
|
||||
use crate::configs::artcraft_website::add_artcraft_website;
|
||||
use crate::configs::fakeyou::add_fakeyou;
|
||||
use crate::configs::fakeyou::add_fakeyou_dev_proxy;
|
||||
use crate::configs::legacy::add_legacy_storyteller_stream;
|
||||
use crate::configs::legacy::add_legacy_trumped;
|
||||
use crate::configs::legacy::add_legacy_vocodes;
|
||||
use crate::configs::legacy::add_power_stream;
|
||||
use crate::configs::realseedance::add_realseedance;
|
||||
use crate::configs::storyteller::add_storyteller;
|
||||
use crate::configs::storyteller::add_storyteller_dev_proxy;
|
||||
use crate::configs::storyteller_board::add_storyteller_board;
|
||||
use crate::configs::storyteller_render::add_storyteller_render;
|
||||
use crate::configs::storyteller_studio::add_storyteller_studio;
|
||||
use crate::configs::tauri::add_tauri;
|
||||
use crate::cors::build_cors_config;
|
||||
use crate::testing::{assert_exact_https_origin, is_origin_allowed, isolated_cors};
|
||||
|
||||
use Env::{Both, DevelopmentOnly, ProductionOnly};
|
||||
|
||||
type AddOrigins = fn(Cors, bool) -> Cors;
|
||||
|
||||
/// Which environments an origin is configured for.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
enum Env {
|
||||
Both,
|
||||
ProductionOnly,
|
||||
DevelopmentOnly,
|
||||
}
|
||||
|
||||
const ALLOWLIST: &[(AddOrigins, Env, &str)] = &[
|
||||
// add_artcraft_2d
|
||||
(add_artcraft_2d, Both, "https://2d.storyteller.ai"),
|
||||
(add_artcraft_2d, Both, "https://2d.getartcraft.com"),
|
||||
(add_artcraft_2d, Both, "http://localhost:5741"),
|
||||
(add_artcraft_2d, Both, "http://localhost:5742"),
|
||||
(add_artcraft_2d, Both, "http://localhost:5743"),
|
||||
|
||||
// add_artcraft_3d
|
||||
(add_artcraft_3d, Both, "https://3d.storyteller.ai"),
|
||||
(add_artcraft_3d, Both, "https://3d.getartcraft.com"),
|
||||
(add_artcraft_3d, Both, "http://localhost:5173"),
|
||||
(add_artcraft_3d, Both, "http://localhost:5174"),
|
||||
(add_artcraft_3d, Both, "http://localhost:5175"),
|
||||
(add_artcraft_3d, Both, "https://macaroni-1.tailce84f.ts.net"),
|
||||
(add_artcraft_3d, Both, "https://halide.tailce84f.ts.net"),
|
||||
(add_artcraft_3d, Both, "https://brandons-macbook-pro.taild62114.ts.net"),
|
||||
|
||||
// add_artcraft_admin_dashboard
|
||||
(add_artcraft_admin_dashboard, Both, "https://artcraft-dashboard.netlify.app"),
|
||||
|
||||
// add_artcraft_desktop
|
||||
(add_artcraft_desktop, Both, "https://desktop.getartcraft.com"),
|
||||
|
||||
// add_artcraft_webapp
|
||||
(add_artcraft_webapp, Both, "https://app.getartcraft.com"),
|
||||
|
||||
// add_artcraft_website
|
||||
(add_artcraft_website, Both, "https://getartcraft.com"),
|
||||
(add_artcraft_website, Both, "https://www.getartcraft.com"),
|
||||
(add_artcraft_website, Both, "https://artcraft.ai"),
|
||||
(add_artcraft_website, Both, "https://www.artcraft.ai"),
|
||||
(add_artcraft_website, Both, "http://localhost:4200"),
|
||||
(add_artcraft_website, Both, "http://localhost:4201"),
|
||||
(add_artcraft_website, Both, "http://localhost:4202"),
|
||||
|
||||
// add_fakeyou
|
||||
(add_fakeyou, Both, "https://engine.fakeyou.com"),
|
||||
(add_fakeyou, ProductionOnly, "https://api.fakeyou.com"),
|
||||
(add_fakeyou, ProductionOnly, "https://fakeyou.com"),
|
||||
(add_fakeyou, ProductionOnly, "https://staging.fakeyou.com"),
|
||||
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com"),
|
||||
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com:7000"),
|
||||
(add_fakeyou, DevelopmentOnly, "http://dev.fakeyou.com:7001"),
|
||||
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com"),
|
||||
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com:7000"),
|
||||
(add_fakeyou, DevelopmentOnly, "https://dev.fakeyou.com:7001"),
|
||||
|
||||
// add_fakeyou_dev_proxy
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:5173"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7000"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7001"),
|
||||
(add_fakeyou_dev_proxy, Both, "http://devproxy.fakeyou.com:7002"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:5173"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7000"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7001"),
|
||||
(add_fakeyou_dev_proxy, Both, "https://devproxy.fakeyou.com:7002"),
|
||||
|
||||
// add_legacy_storyteller_stream
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://api.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://ws.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://api.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://ws.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://staging.obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://staging.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://staging.obs.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://staging.storyteller.stream"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://create.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://obs.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "http://ws.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://create.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://obs.storyteller.ai"),
|
||||
(add_legacy_storyteller_stream, ProductionOnly, "https://ws.storyteller.ai"),
|
||||
|
||||
// add_legacy_trumped
|
||||
(add_legacy_trumped, ProductionOnly, "https://trumped.com"),
|
||||
(add_legacy_trumped, DevelopmentOnly, "http://dev.trumped.com"),
|
||||
(add_legacy_trumped, DevelopmentOnly, "https://dev.trumped.com"),
|
||||
|
||||
// add_legacy_vocodes
|
||||
(add_legacy_vocodes, ProductionOnly, "https://api.vo.codes"),
|
||||
(add_legacy_vocodes, ProductionOnly, "https://vo.codes"),
|
||||
(add_legacy_vocodes, ProductionOnly, "https://vocodes.com"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "http://dev.api.vo.codes"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "http://dev.vo.codes"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "https://dev.api.vo.codes"),
|
||||
(add_legacy_vocodes, DevelopmentOnly, "https://dev.vo.codes"),
|
||||
|
||||
// add_power_stream
|
||||
(add_power_stream, ProductionOnly, "https://dash.power.stream"),
|
||||
(add_power_stream, ProductionOnly, "https://power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "http://dev.dash.power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "http://dev.power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "https://dev.dash.power.stream"),
|
||||
(add_power_stream, DevelopmentOnly, "https://dev.power.stream"),
|
||||
|
||||
// add_realseedance
|
||||
(add_realseedance, Both, "https://realseedance.com"),
|
||||
(add_realseedance, Both, "https://www.realseedance.com"),
|
||||
|
||||
// add_storyteller
|
||||
(add_storyteller, ProductionOnly, "https://engine.storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://studio.storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://api.storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://storyteller.ai"),
|
||||
(add_storyteller, ProductionOnly, "https://staging.storyteller.ai"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:5173"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7000"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7001"),
|
||||
(add_storyteller, DevelopmentOnly, "http://dev.storyteller.ai:7002"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:5173"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7000"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7001"),
|
||||
(add_storyteller, DevelopmentOnly, "https://dev.storyteller.ai:7002"),
|
||||
|
||||
// add_storyteller_dev_proxy
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:5173"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7000"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7001"),
|
||||
(add_storyteller_dev_proxy, Both, "http://devproxy.storyteller.ai:7002"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:5173"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7000"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7001"),
|
||||
(add_storyteller_dev_proxy, Both, "https://devproxy.storyteller.ai:7002"),
|
||||
|
||||
// add_storyteller_board
|
||||
(add_storyteller_board, Both, "https://memeboard.ai"),
|
||||
(add_storyteller_board, Both, "https://dingboard.ai"),
|
||||
(add_storyteller_board, Both, "https://board.storyteller.ai"),
|
||||
(add_storyteller_board, Both, "http://localhost:5173/"),
|
||||
|
||||
// add_storyteller_render
|
||||
(add_storyteller_render, Both, "https://render.storyteller.ai"),
|
||||
|
||||
// add_storyteller_studio
|
||||
(add_storyteller_studio, Both, "https://studio.storyteller.ai"),
|
||||
(add_storyteller_studio, Both, "https://studio-staging.studio.storyteller.ai"),
|
||||
(add_storyteller_studio, Both, "https://studio-testing.studio.storyteller.ai"),
|
||||
(add_storyteller_studio, Both, "http://localhost:5173"),
|
||||
(add_storyteller_studio, Both, "https://animate.storyteller.ai"),
|
||||
|
||||
// add_tauri
|
||||
(add_tauri, Both, "http://tauri.localhost"),
|
||||
(add_tauri, Both, "tauri://localhost"),
|
||||
];
|
||||
|
||||
/// The origins whose `allowed_origin(...)` literal became a `domains` constant.
|
||||
const MIGRATED_ORIGINS: &[&str] = &[
|
||||
"https://getartcraft.com",
|
||||
"https://www.getartcraft.com",
|
||||
"https://app.getartcraft.com",
|
||||
"https://desktop.getartcraft.com",
|
||||
"https://2d.getartcraft.com",
|
||||
"https://3d.getartcraft.com",
|
||||
"https://artcraft.ai",
|
||||
"https://www.artcraft.ai",
|
||||
"https://fakeyou.com",
|
||||
"https://api.fakeyou.com",
|
||||
"https://engine.fakeyou.com",
|
||||
"https://staging.fakeyou.com",
|
||||
"https://storyteller.ai",
|
||||
"https://api.storyteller.ai",
|
||||
"https://engine.storyteller.ai",
|
||||
"https://staging.storyteller.ai",
|
||||
"https://studio.storyteller.ai",
|
||||
"https://studio-staging.studio.storyteller.ai",
|
||||
"https://studio-testing.studio.storyteller.ai",
|
||||
"https://animate.storyteller.ai",
|
||||
"https://board.storyteller.ai",
|
||||
"https://render.storyteller.ai",
|
||||
"https://2d.storyteller.ai",
|
||||
"https://3d.storyteller.ai",
|
||||
];
|
||||
|
||||
mod isolated_module_tests {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn each_module_allows_its_origins_in_production() {
|
||||
let failures = allowed_failures(true, |env| env != DevelopmentOnly, false).await;
|
||||
assert!(failures.is_empty(), "production origins not allowed by their module: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn each_module_allows_its_origins_in_development() {
|
||||
let failures = allowed_failures(false, |env| env != ProductionOnly, false).await;
|
||||
assert!(failures.is_empty(), "development origins not allowed by their module: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn development_only_origins_are_rejected_by_their_module_in_production() {
|
||||
let failures = allowed_failures(true, |env| env == DevelopmentOnly, true).await;
|
||||
assert!(failures.is_empty(), "development-only origins allowed in production: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_only_origins_are_rejected_by_their_module_in_development() {
|
||||
let failures = allowed_failures(false, |env| env == ProductionOnly, true).await;
|
||||
assert!(failures.is_empty(), "production-only origins allowed in development: {:#?}", failures);
|
||||
}
|
||||
|
||||
/// Checks each matching row against its own module's isolated config and
|
||||
/// returns the origins that were allowed (`expect_rejected`) or not.
|
||||
async fn allowed_failures(
|
||||
is_production: bool,
|
||||
select: impl Fn(Env) -> bool,
|
||||
expect_rejected: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let mut failures = Vec::new();
|
||||
for &(add_origins, env, origin) in ALLOWLIST {
|
||||
if !select(env) {
|
||||
continue;
|
||||
}
|
||||
let cors = isolated_cors(add_origins, is_production);
|
||||
if is_origin_allowed(&cors, origin).await == expect_rejected {
|
||||
failures.push(origin);
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
}
|
||||
|
||||
mod full_config_tests {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_config_allows_every_production_origin() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
let failures = full_config_failures(&cors, |env| env != DevelopmentOnly, false).await;
|
||||
assert!(failures.is_empty(), "production origins not allowed: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn development_config_allows_every_development_origin() {
|
||||
let cors = build_cors_config(ServerEnvironment::Development);
|
||||
let failures = full_config_failures(&cors, |env| env != ProductionOnly, false).await;
|
||||
assert!(failures.is_empty(), "development origins not allowed: {:#?}", failures);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_config_rejects_development_only_origins() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
let failures = full_config_failures(&cors, |env| env == DevelopmentOnly, true).await;
|
||||
assert!(failures.is_empty(), "development-only origins allowed in production: {:#?}", failures);
|
||||
}
|
||||
|
||||
/// Every origin that moved to a `domains` constant, checked for lookalikes
|
||||
/// against the full production config (not just the owning module).
|
||||
#[actix_rt::test]
|
||||
async fn production_config_rejects_lookalikes_of_migrated_origins() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in MIGRATED_ORIGINS {
|
||||
assert_exact_https_origin(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn full_config_failures(
|
||||
cors: &Cors,
|
||||
select: impl Fn(Env) -> bool,
|
||||
expect_rejected: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let mut failures = Vec::new();
|
||||
for &(_, env, origin) in ALLOWLIST {
|
||||
if select(env) && is_origin_allowed(cors, origin).await == expect_rejected {
|
||||
failures.push(origin);
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_2D;
|
||||
use domains::storyteller::STORYTELLER_2D;
|
||||
|
||||
pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://2d.storyteller.ai")
|
||||
.allowed_origin("https://2d.getartcraft.com")
|
||||
.allowed_origin(STORYTELLER_2D.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_2D.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-2d.netlify.app")
|
||||
@@ -15,3 +17,35 @@ pub fn add_artcraft_2d(cors: Cors, _is_production: bool) -> Cors {
|
||||
.allowed_origin("http://localhost:5742") // If already started
|
||||
.allowed_origin("http://localhost:5743") // If already started
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::add_artcraft_2d;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_2d, is_production);
|
||||
assert_exact_https_origin(&cors, "https://2d.getartcraft.com").await;
|
||||
assert_exact_https_origin(&cors, "https://2d.storyteller.ai").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tauri_localhost_ports() {
|
||||
let cors = isolated_cors(add_artcraft_2d, true);
|
||||
for origin in ["http://localhost:5741", "http://localhost:5742", "http://localhost:5743"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "http://localhost:5744").await;
|
||||
assert_origin_invalid(&cors, "http://localhost:5173").await; // The 3D engine's port
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_2d, true);
|
||||
assert_netlify_project(&cors, "storyteller-2d.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_3D;
|
||||
use domains::storyteller::STORYTELLER_3D;
|
||||
|
||||
pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://3d.storyteller.ai")
|
||||
.allowed_origin("https://3d.getartcraft.com")
|
||||
.allowed_origin(STORYTELLER_3D.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_3D.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-3d.netlify.app")
|
||||
@@ -18,3 +20,48 @@ pub fn add_artcraft_3d(cors: Cors, _is_production: bool) -> Cors {
|
||||
.allowed_origin("https://halide.tailce84f.ts.net") // TODO
|
||||
.allowed_origin("https://brandons-macbook-pro.taild62114.ts.net") // TODO
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::add_artcraft_3d;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_3d, is_production);
|
||||
assert_exact_https_origin(&cors, "https://3d.getartcraft.com").await;
|
||||
assert_exact_https_origin(&cors, "https://3d.storyteller.ai").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tauri_localhost_ports() {
|
||||
let cors = isolated_cors(add_artcraft_3d, true);
|
||||
for origin in ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "http://localhost:5176").await;
|
||||
assert_origin_invalid(&cors, "http://localhost:5741").await; // The 2D engine's port
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn tailscale_hosts() {
|
||||
let cors = isolated_cors(add_artcraft_3d, true);
|
||||
for origin in [
|
||||
"https://macaroni-1.tailce84f.ts.net",
|
||||
"https://halide.tailce84f.ts.net",
|
||||
"https://brandons-macbook-pro.taild62114.ts.net",
|
||||
] {
|
||||
assert_exact_https_origin(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "https://other-machine.tailce84f.ts.net").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_3d, true);
|
||||
assert_netlify_project(&cors, "storyteller-3d.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,18 @@ pub fn add_artcraft_admin_dashboard(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "artcraft-dashboard.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_netlify_project, isolated_cors};
|
||||
|
||||
use super::add_artcraft_admin_dashboard;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_admin_dashboard, is_production);
|
||||
assert_netlify_project(&cors, "artcraft-dashboard.netlify.app").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use actix_cors::Cors;
|
||||
use domains::artcraft::ARTCRAFT_DESKTOP;
|
||||
|
||||
pub fn add_artcraft_desktop(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Stable identity sent by the Artcraft native HTTP bridge (dev and packaged apps).
|
||||
.allowed_origin(ARTCRAFT_DESKTOP.https_link_without_path())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::dev::Transform;
|
||||
use actix_web::http::{header, Method, StatusCode};
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::cors::build_cors_config;
|
||||
use crate::testing::{assert_origin_invalid, assert_origin_ok};
|
||||
|
||||
const DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn desktop_requests_and_preflights_allow_credentials() {
|
||||
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
|
||||
let service = build_cors_config(environment)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for method in [Method::GET, Method::POST, Method::OPTIONS] {
|
||||
let mut request = TestRequest::default()
|
||||
.uri("/v1/session")
|
||||
.insert_header((header::ORIGIN, DESKTOP_ORIGIN));
|
||||
if method == Method::OPTIONS {
|
||||
request = request
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
|
||||
}
|
||||
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), DESKTOP_ORIGIN);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn desktop_origin_is_an_exact_https_allowlist_entry() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in [
|
||||
"http://desktop.getartcraft.com",
|
||||
"https://desktop.getartcraft.com:8443",
|
||||
"https://desktop.getartcraft.com.example.com",
|
||||
"https://sub.desktop.getartcraft.com",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn existing_desktop_clients_remain_allowed() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in ["https://studio.storyteller.ai", "http://tauri.localhost", "tauri://localhost"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,34 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_WEBAPP;
|
||||
|
||||
pub fn add_artcraft_webapp(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Actual domains
|
||||
.allowed_origin("https://app.getartcraft.com")
|
||||
.allowed_origin(ARTCRAFT_WEBAPP.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "artcraft-webapp.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, isolated_cors};
|
||||
|
||||
use super::add_artcraft_webapp;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn webapp_domain() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_webapp, is_production);
|
||||
assert_exact_https_origin(&cors, "https://app.getartcraft.com").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_webapp, true);
|
||||
assert_netlify_project(&cors, "artcraft-webapp.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
use actix_cors::Cors;
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::artcraft::ARTCRAFT_AI;
|
||||
use domains::artcraft::ARTCRAFT_AI_WWW;
|
||||
use domains::artcraft::ARTCRAFT_WEBSITE;
|
||||
use domains::artcraft::ARTCRAFT_WEBSITE_WWW;
|
||||
|
||||
pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Actual domains
|
||||
.allowed_origin("https://getartcraft.com")
|
||||
.allowed_origin("https://www.getartcraft.com")
|
||||
.allowed_origin(ARTCRAFT_WEBSITE.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_WEBSITE_WWW.https_link_without_path())
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://artcraft.ai")
|
||||
.allowed_origin("https://www.artcraft.ai")
|
||||
.allowed_origin(ARTCRAFT_AI.https_link_without_path())
|
||||
.allowed_origin(ARTCRAFT_AI_WWW.https_link_without_path())
|
||||
// Development against production
|
||||
.allowed_origin("http://localhost:4200")
|
||||
.allowed_origin("http://localhost:4201")
|
||||
@@ -18,3 +22,42 @@ pub fn add_artcraft_website(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "artcraft-website.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::add_artcraft_website;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn website_domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_artcraft_website, is_production);
|
||||
for origin in [
|
||||
"https://getartcraft.com",
|
||||
"https://www.getartcraft.com",
|
||||
"https://artcraft.ai",
|
||||
"https://www.artcraft.ai",
|
||||
] {
|
||||
assert_exact_https_origin(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local frontend builds talk to the production API.
|
||||
#[actix_rt::test]
|
||||
async fn localhost_development_against_production() {
|
||||
let cors = isolated_cors(add_artcraft_website, true);
|
||||
for origin in ["http://localhost:4200", "http://localhost:4201", "http://localhost:4202"] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
assert_origin_invalid(&cors, "http://localhost:4203").await;
|
||||
assert_origin_invalid(&cors, "https://localhost:4200").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_artcraft_website, true);
|
||||
assert_netlify_project(&cors, "artcraft-website.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,48 @@ pub fn add_development_only(cors: Cors) -> Cors {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::testing::{assert_origin_invalid, assert_origin_ok};
|
||||
|
||||
use super::add_development_only;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn any_localhost_is_allowed() {
|
||||
let cors = development_only_cors();
|
||||
for origin in [
|
||||
"http://localhost",
|
||||
"http://localhost:3000",
|
||||
"https://localhost:8443",
|
||||
"http://127.0.0.1",
|
||||
"http://127.0.0.1:5173",
|
||||
] {
|
||||
assert_origin_ok(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn non_localhost_is_rejected() {
|
||||
let cors = development_only_cors();
|
||||
for origin in [
|
||||
"https://getartcraft.com",
|
||||
"http://localhost.evil.example",
|
||||
"http://evil.localhost.example",
|
||||
"http://127.0.0.2",
|
||||
"http://192.168.1.10",
|
||||
"http://[::1]", // NB: IPv6 loopback isn't allowed today
|
||||
"not a url",
|
||||
"null",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn development_only_cors() -> Cors {
|
||||
add_development_only(Cors::default())
|
||||
.block_on_origin_mismatch(true)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
use actix_cors::Cors;
|
||||
use log::warn;
|
||||
use url::{Host, Url};
|
||||
use domains::fakeyou::FAKEYOU;
|
||||
use domains::fakeyou::FAKEYOU_API;
|
||||
use domains::fakeyou::FAKEYOU_ENGINE;
|
||||
use domains::fakeyou::FAKEYOU_STAGING;
|
||||
|
||||
pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
|
||||
if is_production {
|
||||
cors
|
||||
// Storyteller Engine (Production)
|
||||
.allowed_origin("https://engine.fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path())
|
||||
// FakeYou (Production)
|
||||
.allowed_origin("https://api.fakeyou.com")
|
||||
.allowed_origin("https://fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_API.https_link_without_path())
|
||||
.allowed_origin(FAKEYOU.https_link_without_path())
|
||||
// FakeYou (Staging)
|
||||
.allowed_origin("https://staging.fakeyou.com")
|
||||
.allowed_origin(FAKEYOU_STAGING.https_link_without_path())
|
||||
// Allow Netlify domains within "fakeyou" project.
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
let maybe_url = origin.to_str()
|
||||
@@ -50,7 +54,7 @@ pub fn add_fakeyou(cors: Cors, is_production: bool) -> Cors {
|
||||
.allowed_origin("https://dev.fakeyou.com:7000") // Yarn default port
|
||||
.allowed_origin("https://dev.fakeyou.com:7001") // NB: Mac frontend
|
||||
// Storyteller Engine (Development)
|
||||
.allowed_origin("https://engine.fakeyou.com") // NB: We use prod for integration testing
|
||||
.allowed_origin(FAKEYOU_ENGINE.https_link_without_path()) // NB: We use prod for integration testing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,3 +75,35 @@ pub fn add_legacy_trumped(cors: Cors, is_production: bool) -> Cors {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_origin_invalid, assert_origin_ok, isolated_cors};
|
||||
|
||||
use super::{add_legacy_storyteller_stream, add_legacy_trumped, add_legacy_vocodes, add_power_stream};
|
||||
|
||||
// NB: The full per-environment origin lists are pinned in allowlist_regression_tests.
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn legacy_storyteller_stream_has_no_development_origins() {
|
||||
let cors = isolated_cors(add_legacy_storyteller_stream, false);
|
||||
assert_origin_invalid(&cors, "https://storyteller.stream").await;
|
||||
assert_origin_invalid(&cors, "https://create.storyteller.ai").await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_hosts_are_exact() {
|
||||
let power_stream = isolated_cors(add_power_stream, true);
|
||||
assert_origin_ok(&power_stream, "https://power.stream").await;
|
||||
assert_origin_invalid(&power_stream, "http://power.stream").await;
|
||||
assert_origin_invalid(&power_stream, "https://power.stream.evil.example").await;
|
||||
|
||||
let vocodes = isolated_cors(add_legacy_vocodes, true);
|
||||
assert_origin_ok(&vocodes, "https://vo.codes").await;
|
||||
assert_origin_invalid(&vocodes, "https://evil.vo.codes").await;
|
||||
|
||||
let trumped = isolated_cors(add_legacy_trumped, true);
|
||||
assert_origin_ok(&trumped, "https://trumped.com").await;
|
||||
assert_origin_invalid(&trumped, "https://trumped.com:8443").await;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub (crate) mod artcraft_admin_dashboard;
|
||||
pub (crate) mod artcraft_2d;
|
||||
pub (crate) mod artcraft_3d;
|
||||
pub (crate) mod artcraft_desktop;
|
||||
pub (crate) mod artcraft_webapp;
|
||||
pub (crate) mod artcraft_website;
|
||||
pub (crate) mod development_only;
|
||||
@@ -11,4 +12,4 @@ pub (crate) mod storyteller;
|
||||
pub (crate) mod storyteller_board;
|
||||
pub (crate) mod storyteller_render;
|
||||
pub (crate) mod storyteller_studio;
|
||||
pub (crate) mod tauri;
|
||||
pub (crate) mod tauri;
|
||||
@@ -12,3 +12,25 @@ pub fn add_realseedance(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "real-seedance.netlify.app")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testing::{assert_exact_https_origin, assert_netlify_project, isolated_cors};
|
||||
|
||||
use super::add_realseedance;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn domains() {
|
||||
for is_production in [true, false] {
|
||||
let cors = isolated_cors(add_realseedance, is_production);
|
||||
assert_exact_https_origin(&cors, "https://realseedance.com").await;
|
||||
assert_exact_https_origin(&cors, "https://www.realseedance.com").await;
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn netlify_project() {
|
||||
let cors = isolated_cors(add_realseedance, true);
|
||||
assert_netlify_project(&cors, "real-seedance.netlify.app").await;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER;
|
||||
use domains::storyteller::STORYTELLER_API;
|
||||
use domains::storyteller::STORYTELLER_ENGINE;
|
||||
use domains::storyteller::STORYTELLER_STAGING;
|
||||
use domains::storyteller::STORYTELLER_STUDIO;
|
||||
|
||||
pub fn add_storyteller(cors: Cors, is_production: bool) -> Cors {
|
||||
if is_production {
|
||||
cors
|
||||
// Storyteller Engine (Production)
|
||||
.allowed_origin("https://engine.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_ENGINE.https_link_without_path())
|
||||
// Storyteller Studio (Production)
|
||||
.allowed_origin("https://studio.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
|
||||
// Storyteller.ai (Production)
|
||||
.allowed_origin("https://api.storyteller.ai")
|
||||
.allowed_origin("https://storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_API.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER.https_link_without_path())
|
||||
// Storyteller.ai (Staging)
|
||||
.allowed_origin("https://staging.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_STAGING.https_link_without_path())
|
||||
// Allow Netlify domains within "storyteller-ai" project.
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-ai.netlify.app")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_BOARD;
|
||||
|
||||
pub fn add_storyteller_board(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://memeboard.ai")
|
||||
.allowed_origin("https://dingboard.ai")
|
||||
.allowed_origin("https://board.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_BOARD.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-board.netlify.app")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_RENDER;
|
||||
|
||||
pub fn add_storyteller_render(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors
|
||||
// Hypothetical domains
|
||||
.allowed_origin("https://render.storyteller.ai")
|
||||
.allowed_origin(STORYTELLER_RENDER.https_link_without_path())
|
||||
// Netlify project
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storyteller-render.netlify.app")
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use actix_cors::Cors;
|
||||
|
||||
use crate::util::netlify_branch_domain_matches::netlify_branch_domain_matches;
|
||||
use domains::storyteller::STORYTELLER_ANIMATE;
|
||||
use domains::storyteller::STORYTELLER_STUDIO;
|
||||
use domains::storyteller::STORYTELLER_STUDIO_STAGING;
|
||||
use domains::storyteller::STORYTELLER_STUDIO_TESTING;
|
||||
|
||||
pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
|
||||
cors.allowed_origin("https://studio.storyteller.ai")
|
||||
.allowed_origin("https://studio-staging.studio.storyteller.ai")
|
||||
.allowed_origin("https://studio-testing.studio.storyteller.ai")
|
||||
cors.allowed_origin(STORYTELLER_STUDIO.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER_STUDIO_STAGING.https_link_without_path())
|
||||
.allowed_origin(STORYTELLER_STUDIO_TESTING.https_link_without_path())
|
||||
.allowed_origin_fn(|origin, _req_head| {
|
||||
netlify_branch_domain_matches(origin, "storytellerstudio.netlify.app")
|
||||
})
|
||||
@@ -13,7 +17,7 @@ pub fn add_storyteller_studio(cors: Cors, _is_production: bool) -> Cors {
|
||||
netlify_branch_domain_matches(origin, "pipeline-gottagofast.netlify.app")
|
||||
})
|
||||
.allowed_origin("http://localhost:5173")
|
||||
.allowed_origin("https://animate.storyteller.ai") // NB: Gen2 Studio
|
||||
.allowed_origin(STORYTELLER_ANIMATE.https_link_without_path()) // NB: Gen2 Studio
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,3 +7,86 @@ pub fn add_tauri(cors: Cors, _is_production: bool) -> Cors {
|
||||
// Tauri Mac
|
||||
.allowed_origin("tauri://localhost")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::dev::Transform;
|
||||
use actix_web::http::{header, Method, StatusCode};
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use crate::cors::build_cors_config;
|
||||
use crate::testing::assert_origin_invalid;
|
||||
|
||||
const WINDOWS_ORIGIN: &str = "http://tauri.localhost";
|
||||
const MAC_ORIGIN: &str = "tauri://localhost";
|
||||
|
||||
mod windows {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn requests_and_preflights_allow_credentials() {
|
||||
assert_requests_and_preflights_allow_credentials(WINDOWS_ORIGIN).await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_rejects_origin_variants() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in [
|
||||
"https://tauri.localhost",
|
||||
"http://tauri.localhost:8080",
|
||||
"http://tauri.localhost.example.com",
|
||||
"http://sub.tauri.localhost",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod mac {
|
||||
use super::*;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn requests_and_preflights_allow_credentials() {
|
||||
assert_requests_and_preflights_allow_credentials(MAC_ORIGIN).await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn production_rejects_origin_variants() {
|
||||
let cors = build_cors_config(ServerEnvironment::Production);
|
||||
for origin in [
|
||||
"http://localhost",
|
||||
"https://localhost",
|
||||
"tauri://localhost:8080",
|
||||
"tauri://localhost.example.com",
|
||||
"tauri://sub.localhost",
|
||||
] {
|
||||
assert_origin_invalid(&cors, origin).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_requests_and_preflights_allow_credentials(origin: &str) {
|
||||
for environment in [ServerEnvironment::Production, ServerEnvironment::Development] {
|
||||
let service = build_cors_config(environment)
|
||||
.new_transform(test::ok_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for method in [Method::GET, Method::POST, Method::OPTIONS] {
|
||||
let mut request = TestRequest::default()
|
||||
.uri("/v1/session")
|
||||
.insert_header((header::ORIGIN, origin));
|
||||
if method == Method::OPTIONS {
|
||||
request = request
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||
.insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "content-type,session,x-artcraft-version"));
|
||||
}
|
||||
let response = test::call_service(&service, request.method(method).to_srv_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK, "Origin: {}", origin);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), origin);
|
||||
assert_eq!(response.headers().get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use log::info;
|
||||
use crate::configs::artcraft_2d::add_artcraft_2d;
|
||||
use crate::configs::artcraft_3d::add_artcraft_3d;
|
||||
use crate::configs::artcraft_admin_dashboard::add_artcraft_admin_dashboard;
|
||||
use crate::configs::artcraft_desktop::add_artcraft_desktop;
|
||||
use crate::configs::artcraft_webapp::add_artcraft_webapp;
|
||||
use crate::configs::artcraft_website::add_artcraft_website;
|
||||
use crate::configs::development_only::add_development_only;
|
||||
@@ -48,6 +49,7 @@ fn do_build_cors_config(is_production: bool) -> Cors {
|
||||
|
||||
// Artcraft
|
||||
cors = add_tauri(cors, is_production);
|
||||
cors = add_artcraft_desktop(cors, is_production);
|
||||
cors = add_artcraft_2d(cors, is_production);
|
||||
cors = add_artcraft_3d(cors, is_production);
|
||||
cors = add_artcraft_webapp(cors, is_production);
|
||||
|
||||
@@ -27,3 +27,6 @@ pub (crate) mod util;
|
||||
|
||||
#[cfg(test)]
|
||||
pub (crate) mod testing;
|
||||
|
||||
#[cfg(test)]
|
||||
mod allowlist_regression_tests;
|
||||
@@ -7,6 +7,53 @@ use actix_web::test;
|
||||
use actix_web::test::TestRequest;
|
||||
use speculoos::asserting;
|
||||
|
||||
/// A config containing ONLY one module's origins, finished the same way as
|
||||
/// the production config (mismatched origins get a 400). Testing modules in
|
||||
/// isolation proves each module allows its own origins, rather than passing
|
||||
/// because some other module happens to allow them too.
|
||||
pub (crate) fn isolated_cors(add_origins: fn(Cors, bool) -> Cors, is_production: bool) -> Cors {
|
||||
add_origins(Cors::default(), is_production)
|
||||
.block_on_origin_mismatch(true)
|
||||
}
|
||||
|
||||
/// Whether `cors` allows a simple request from `origin`, for tests that check
|
||||
/// many origins and report every failure at once.
|
||||
pub (crate) async fn is_origin_allowed(cors: &Cors, origin: &str) -> bool {
|
||||
let status = make_test_request(cors, origin).await.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::BAD_REQUEST,
|
||||
"unexpected status {} for origin {}", status, origin);
|
||||
status == StatusCode::OK
|
||||
}
|
||||
|
||||
/// Assert that `cors` allows the exact `https://` origin but none of its
|
||||
/// lookalikes: plain http, another port, a suffix attack, or a sub-subdomain.
|
||||
/// Don't use this for Netlify hosts: their matcher checks the hostname only.
|
||||
pub (crate) async fn assert_exact_https_origin(cors: &Cors, origin: &str) {
|
||||
let host = origin.strip_prefix("https://")
|
||||
.unwrap_or_else(|| panic!("expected an https:// origin, got {}", origin));
|
||||
|
||||
assert_origin_ok(cors, origin).await;
|
||||
assert_origin_invalid(cors, &format!("http://{}", host)).await;
|
||||
assert_origin_invalid(cors, &format!("https://{}:8443", host)).await;
|
||||
assert_origin_invalid(cors, &format!("https://{}.evil.example", host)).await;
|
||||
assert_origin_invalid(cors, &format!("https://evil.{}", host)).await;
|
||||
}
|
||||
|
||||
/// Assert that `cors` allows a Netlify project's main, branch and
|
||||
/// deploy-preview hosts, and rejects other projects' hosts.
|
||||
pub (crate) async fn assert_netlify_project(cors: &Cors, netlify_hostname: &str) {
|
||||
assert_origin_ok(cors, &format!("https://{}", netlify_hostname)).await;
|
||||
assert_origin_ok(cors, &format!("https://my-branch--{}", netlify_hostname)).await;
|
||||
assert_origin_ok(cors, &format!("https://deploy-preview-123--{}", netlify_hostname)).await;
|
||||
|
||||
assert_origin_invalid(cors, "https://unrelated-project.netlify.app").await;
|
||||
assert_origin_invalid(cors, "https://deploy-preview-123--unrelated-project.netlify.app").await;
|
||||
assert_origin_invalid(cors, &format!("https://{}.evil.example", netlify_hostname)).await;
|
||||
// A hyphen alone isn't a branch separator; Netlify branch hosts use "--".
|
||||
assert_origin_invalid(cors, &format!("https://evil-{}", netlify_hostname)).await;
|
||||
}
|
||||
|
||||
pub (crate) async fn assert_origin_ok(cors: &Cors, hostname: &str) {
|
||||
let response = make_test_request(cors, hostname).await;
|
||||
asserting(&format!("Hostname {} is valid", hostname))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "domains"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "domains"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Adds `Domain::https_url()`, returning a cached `&'static url::Url`.
|
||||
url = ["dep:url"]
|
||||
|
||||
# No dependencies by default: the domain constants and their string forms are
|
||||
# built entirely at compile time.
|
||||
[dependencies]
|
||||
url = { workspace = true, optional = true }
|
||||
@@ -0,0 +1,69 @@
|
||||
//! ArtCraft domains.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Public marketing website (Next.js).
|
||||
pub const ARTCRAFT_WEBSITE: Domain = domain!("getartcraft.com");
|
||||
pub const ARTCRAFT_WEBSITE_WWW: Domain = domain!("www.getartcraft.com");
|
||||
|
||||
/// Browser web application.
|
||||
pub const ARTCRAFT_WEBAPP: Domain = domain!("app.getartcraft.com");
|
||||
|
||||
/// Desktop app landing page.
|
||||
pub const ARTCRAFT_DESKTOP: Domain = domain!("desktop.getartcraft.com");
|
||||
|
||||
/// HTTP API.
|
||||
pub const ARTCRAFT_API: Domain = domain!("api.getartcraft.com");
|
||||
|
||||
pub const ARTCRAFT_2D: Domain = domain!("2d.getartcraft.com");
|
||||
pub const ARTCRAFT_3D: Domain = domain!("3d.getartcraft.com");
|
||||
|
||||
/// Alternate marketing domain.
|
||||
pub const ARTCRAFT_AI: Domain = domain!("artcraft.ai");
|
||||
pub const ARTCRAFT_AI_WWW: Domain = domain!("www.artcraft.ai");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(ARTCRAFT_WEBSITE.https_link_without_path(), "https://getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_WEBSITE_WWW.https_link_without_path(), "https://www.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp() {
|
||||
assert_eq!(ARTCRAFT_WEBAPP.https_link_without_path(), "https://app.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop() {
|
||||
assert_eq!(ARTCRAFT_DESKTOP.bare_domain(), "desktop.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_link_with_root_path(), "https://desktop.getartcraft.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(ARTCRAFT_API.https_link_without_path(), "https://api.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_d_and_three_d() {
|
||||
assert_eq!(ARTCRAFT_2D.https_link_without_path(), "https://2d.getartcraft.com");
|
||||
assert_eq!(ARTCRAFT_3D.https_link_without_path(), "https://3d.getartcraft.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artcraft_ai() {
|
||||
assert_eq!(ARTCRAFT_AI.https_link_without_path(), "https://artcraft.ai");
|
||||
assert_eq!(ARTCRAFT_AI_WWW.https_link_without_path(), "https://www.artcraft.ai");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
#[test]
|
||||
fn desktop_url() {
|
||||
assert_eq!(ARTCRAFT_DESKTOP.https_url().as_str(), "https://desktop.getartcraft.com/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/// A validated domain name, with its `https://` link forms precomputed at
|
||||
/// compile time. Build one with the [`domain!`](crate::domain) macro:
|
||||
///
|
||||
/// ```
|
||||
/// use domains::{domain, Domain};
|
||||
///
|
||||
/// const EXAMPLE: Domain = domain!("desktop.example.com");
|
||||
///
|
||||
/// assert_eq!(EXAMPLE.bare_domain(), "desktop.example.com");
|
||||
/// assert_eq!(EXAMPLE.https_link_without_path(), "https://desktop.example.com");
|
||||
/// assert_eq!(EXAMPLE.https_link_with_root_path(), "https://desktop.example.com/");
|
||||
/// ```
|
||||
///
|
||||
/// An invalid literal fails to compile:
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// const BAD: domains::Domain = domains::domain!("https://example.com");
|
||||
/// ```
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Domain {
|
||||
bare: &'static str,
|
||||
https_without_path: &'static str,
|
||||
https_with_root_path: &'static str,
|
||||
}
|
||||
|
||||
/// Declares a [`Domain`] from a string literal. Validation runs at compile
|
||||
/// time: an invalid domain is a build error, never a runtime panic.
|
||||
#[macro_export]
|
||||
macro_rules! domain {
|
||||
($domain:literal) => {
|
||||
$crate::Domain::__from_macro(
|
||||
$domain,
|
||||
::core::concat!("https://", $domain),
|
||||
::core::concat!("https://", $domain, "/"),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
impl Domain {
|
||||
/// Only for the `domain!` macro, which guarantees the three strings agree.
|
||||
/// Panics (a compile error in const context) on an invalid domain.
|
||||
#[doc(hidden)]
|
||||
pub const fn __from_macro(
|
||||
bare: &'static str,
|
||||
https_without_path: &'static str,
|
||||
https_with_root_path: &'static str,
|
||||
) -> Self {
|
||||
crate::validate::assert_valid_domain(bare);
|
||||
Self { bare, https_without_path, https_with_root_path }
|
||||
}
|
||||
|
||||
/// eg. `desktop.getartcraft.com`
|
||||
pub const fn bare_domain(&self) -> &'static str {
|
||||
self.bare
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com`. This is also the browser `Origin`
|
||||
/// header value, so use it for CORS and origin allowlists.
|
||||
pub const fn https_link_without_path(&self) -> &'static str {
|
||||
self.https_without_path
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com/`
|
||||
pub const fn https_link_with_root_path(&self) -> &'static str {
|
||||
self.https_with_root_path
|
||||
}
|
||||
|
||||
/// eg. `https://desktop.getartcraft.com/` as a `Url`.
|
||||
///
|
||||
/// Parsed on first use per domain, then served from a process-wide cache,
|
||||
/// so every call returns the same `&'static Url`.
|
||||
#[cfg(feature = "url")]
|
||||
pub fn https_url(&self) -> &'static url::Url {
|
||||
crate::url_cache::https_url(self.bare, self.https_with_root_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Domain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Domain({})", self.bare)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Domain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.bare)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Domain;
|
||||
|
||||
const TEST_DOMAIN: Domain = domain!("desktop.example.com");
|
||||
|
||||
#[test]
|
||||
fn bare_domain() {
|
||||
assert_eq!(TEST_DOMAIN.bare_domain(), "desktop.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_links() {
|
||||
assert_eq!(TEST_DOMAIN.https_link_without_path(), "https://desktop.example.com");
|
||||
assert_eq!(TEST_DOMAIN.https_link_with_root_path(), "https://desktop.example.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_in_const_context() {
|
||||
const ORIGINS: &[&str] = &[TEST_DOMAIN.https_link_without_path()];
|
||||
assert_eq!(ORIGINS, &["https://desktop.example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_debug() {
|
||||
assert_eq!(TEST_DOMAIN.to_string(), "desktop.example.com");
|
||||
assert_eq!(format!("{TEST_DOMAIN:?}"), "Domain(desktop.example.com)");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
mod url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_url_has_root_path() {
|
||||
let url = TEST_DOMAIN.https_url();
|
||||
assert_eq!(url.as_str(), "https://desktop.example.com/");
|
||||
assert_eq!(url.scheme(), "https");
|
||||
assert_eq!(url.host_str(), Some("desktop.example.com"));
|
||||
assert_eq!(url.path(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_is_parsed_once_and_shared() {
|
||||
let first = TEST_DOMAIN.https_url();
|
||||
let second = TEST_DOMAIN.https_url();
|
||||
assert!(std::ptr::eq(first, second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_is_shared_across_threads() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<Domain>();
|
||||
assert_send_sync::<&'static url::Url>();
|
||||
|
||||
// Borrowed into 'static threads with no Arc or cloning; every thread
|
||||
// sees the same parsed instance.
|
||||
let main_thread_url = TEST_DOMAIN.https_url();
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| std::thread::spawn(|| TEST_DOMAIN.https_url()))
|
||||
.collect();
|
||||
for handle in handles {
|
||||
assert!(std::ptr::eq(handle.join().unwrap(), main_thread_url));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_url_matches_string_forms() {
|
||||
let url = TEST_DOMAIN.https_url();
|
||||
assert_eq!(url.as_str(), TEST_DOMAIN.https_link_with_root_path());
|
||||
assert_eq!(url.origin().ascii_serialization(), TEST_DOMAIN.https_link_without_path());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! FakeYou domains.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Main website.
|
||||
pub const FAKEYOU: Domain = domain!("fakeyou.com");
|
||||
|
||||
/// HTTP API.
|
||||
pub const FAKEYOU_API: Domain = domain!("api.fakeyou.com");
|
||||
|
||||
/// Engine frontend (also used against production for integration testing).
|
||||
pub const FAKEYOU_ENGINE: Domain = domain!("engine.fakeyou.com");
|
||||
|
||||
pub const FAKEYOU_STAGING: Domain = domain!("staging.fakeyou.com");
|
||||
|
||||
/// Local development hosts (resolve to a developer machine).
|
||||
pub const FAKEYOU_DEV: Domain = domain!("dev.fakeyou.com");
|
||||
pub const FAKEYOU_DEV_PROXY: Domain = domain!("devproxy.fakeyou.com");
|
||||
|
||||
/// Production media CDN. Serves both FakeYou and Storyteller media.
|
||||
pub const FAKEYOU_CDN: Domain = domain!("cdn-2.fakeyou.com");
|
||||
|
||||
/// Development media CDN (a Cloudflare R2 public bucket). Serves both FakeYou
|
||||
/// and Storyteller development media, like `FAKEYOU_CDN` does in production.
|
||||
pub const FAKEYOU_DEV_CDN: Domain = domain!("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(FAKEYOU.bare_domain(), "fakeyou.com");
|
||||
assert_eq!(FAKEYOU.https_link_without_path(), "https://fakeyou.com");
|
||||
assert_eq!(FAKEYOU.https_link_with_root_path(), "https://fakeyou.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(FAKEYOU_API.https_link_without_path(), "https://api.fakeyou.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_and_staging() {
|
||||
assert_eq!(FAKEYOU_ENGINE.https_link_without_path(), "https://engine.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_STAGING.https_link_without_path(), "https://staging.fakeyou.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(FAKEYOU_DEV.bare_domain(), "dev.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_DEV_PROXY.bare_domain(), "devproxy.fakeyou.com");
|
||||
}
|
||||
|
||||
mod cdn {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn production() {
|
||||
assert_eq!(FAKEYOU_CDN.bare_domain(), "cdn-2.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_CDN.https_link_without_path(), "https://cdn-2.fakeyou.com");
|
||||
assert_eq!(FAKEYOU_CDN.https_link_with_root_path(), "https://cdn-2.fakeyou.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(FAKEYOU_DEV_CDN.https_link_without_path(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev");
|
||||
}
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
#[test]
|
||||
fn urls() {
|
||||
assert_eq!(FAKEYOU_CDN.https_url().as_str(), "https://cdn-2.fakeyou.com/");
|
||||
assert_eq!(FAKEYOU_DEV_CDN.https_url().as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! First-party domain names, validated at compile time.
|
||||
//!
|
||||
//! ```
|
||||
//! use domains::artcraft::ARTCRAFT_DESKTOP;
|
||||
//!
|
||||
//! assert_eq!(ARTCRAFT_DESKTOP.https_link_without_path(), "https://desktop.getartcraft.com");
|
||||
//! ```
|
||||
//!
|
||||
//! Enable the `url` feature for `Domain::https_url()`, a cached `&'static url::Url`.
|
||||
|
||||
pub mod artcraft;
|
||||
pub mod fakeyou;
|
||||
pub mod storyteller;
|
||||
|
||||
mod domain;
|
||||
mod validate;
|
||||
|
||||
#[cfg(feature = "url")]
|
||||
mod url_cache;
|
||||
|
||||
pub use domain::Domain;
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Storyteller domains. Storyteller media is served from `fakeyou::FAKEYOU_CDN`.
|
||||
|
||||
use crate::{Domain, domain};
|
||||
|
||||
/// Main website.
|
||||
pub const STORYTELLER: Domain = domain!("storyteller.ai");
|
||||
|
||||
/// HTTP API.
|
||||
pub const STORYTELLER_API: Domain = domain!("api.storyteller.ai");
|
||||
|
||||
pub const STORYTELLER_ENGINE: Domain = domain!("engine.storyteller.ai");
|
||||
pub const STORYTELLER_STAGING: Domain = domain!("staging.storyteller.ai");
|
||||
|
||||
/// Storyteller Studio and its deploy environments.
|
||||
pub const STORYTELLER_STUDIO: Domain = domain!("studio.storyteller.ai");
|
||||
pub const STORYTELLER_STUDIO_STAGING: Domain = domain!("studio-staging.studio.storyteller.ai");
|
||||
pub const STORYTELLER_STUDIO_TESTING: Domain = domain!("studio-testing.studio.storyteller.ai");
|
||||
|
||||
/// Gen2 Studio.
|
||||
pub const STORYTELLER_ANIMATE: Domain = domain!("animate.storyteller.ai");
|
||||
|
||||
pub const STORYTELLER_BOARD: Domain = domain!("board.storyteller.ai");
|
||||
pub const STORYTELLER_RENDER: Domain = domain!("render.storyteller.ai");
|
||||
|
||||
/// Legacy hosts for the ArtCraft 2D and 3D apps.
|
||||
pub const STORYTELLER_2D: Domain = domain!("2d.storyteller.ai");
|
||||
pub const STORYTELLER_3D: Domain = domain!("3d.storyteller.ai");
|
||||
|
||||
/// Local development hosts (resolve to a developer machine).
|
||||
pub const STORYTELLER_DEV: Domain = domain!("dev.storyteller.ai");
|
||||
pub const STORYTELLER_DEV_PROXY: Domain = domain!("devproxy.storyteller.ai");
|
||||
|
||||
/// Storyteller Studio static assets.
|
||||
pub const STORYTELLER_CDN: Domain = domain!("cdn.storyteller.ai");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn website() {
|
||||
assert_eq!(STORYTELLER.bare_domain(), "storyteller.ai");
|
||||
assert_eq!(STORYTELLER.https_link_without_path(), "https://storyteller.ai");
|
||||
assert_eq!(STORYTELLER.https_link_with_root_path(), "https://storyteller.ai/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api() {
|
||||
assert_eq!(STORYTELLER_API.https_link_without_path(), "https://api.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio() {
|
||||
assert_eq!(STORYTELLER_STUDIO.https_link_without_path(), "https://studio.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_STUDIO_STAGING.https_link_without_path(), "https://studio-staging.studio.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_STUDIO_TESTING.https_link_without_path(), "https://studio-testing.studio.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps() {
|
||||
assert_eq!(STORYTELLER_BOARD.https_link_without_path(), "https://board.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_RENDER.https_link_without_path(), "https://render.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_2D.https_link_without_path(), "https://2d.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_3D.https_link_without_path(), "https://3d.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development() {
|
||||
assert_eq!(STORYTELLER_DEV.bare_domain(), "dev.storyteller.ai");
|
||||
assert_eq!(STORYTELLER_DEV_PROXY.bare_domain(), "devproxy.storyteller.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn() {
|
||||
assert_eq!(STORYTELLER_CDN.https_link_without_path(), "https://cdn.storyteller.ai");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Process-wide cache backing `Domain::https_url()`.
|
||||
//!
|
||||
//! `Url::parse` can't run at compile time, and `Domain` stays free of
|
||||
//! interior mutability so its constants remain usable in `const` contexts.
|
||||
//! So each domain's `Url` is parsed on first use and leaked into this map.
|
||||
//! Domains only come from `domain!` literals, so the set (and the leak) is
|
||||
//! bounded by the constants in the source code.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use url::Url;
|
||||
|
||||
static URLS: Mutex<BTreeMap<&'static str, &'static Url>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
pub(crate) fn https_url(bare: &'static str, https_with_root_path: &'static str) -> &'static Url {
|
||||
// The map is only ever inserted into, so a poisoned lock still holds
|
||||
// valid entries.
|
||||
let mut urls = URLS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
urls.entry(bare).or_insert_with(|| {
|
||||
// The domain was validated at compile time, so this parse can't fail.
|
||||
let url = Url::parse(https_with_root_path).expect("compile-time validated domain should parse");
|
||||
Box::leak(Box::new(url))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Compile-time domain name validation.
|
||||
//!
|
||||
//! `assert_valid_domain` is a `const fn` that panics on an invalid name. The
|
||||
//! `domain!` macro calls it while evaluating a `const`/`static`, where a panic
|
||||
//! is a compile error, so a bad domain literal never builds.
|
||||
|
||||
const MAX_DOMAIN_LEN: usize = 253;
|
||||
const MAX_LABEL_LEN: usize = 63;
|
||||
|
||||
/// Panics (a compile error in const context) unless `domain` is a canonical,
|
||||
/// fully-qualified DNS name: lowercase `a-z`, `0-9` and `-` labels separated
|
||||
/// by single dots, at least two labels, no scheme, port, path or trailing dot,
|
||||
/// and a non-numeric top-level label (so IP addresses are rejected).
|
||||
pub const fn assert_valid_domain(domain: &str) {
|
||||
match check_domain(domain) {
|
||||
Ok(()) => {}
|
||||
Err(DomainError::Empty) => panic!("invalid domain: empty"),
|
||||
Err(DomainError::TooLong) => panic!("invalid domain: longer than 253 bytes"),
|
||||
Err(DomainError::EmptyLabel) => panic!("invalid domain: empty label (leading, trailing or doubled dot)"),
|
||||
Err(DomainError::LabelTooLong) => panic!("invalid domain: label longer than 63 bytes"),
|
||||
Err(DomainError::InvalidCharacter) => panic!("invalid domain: only lowercase a-z, 0-9, '-' and '.' are allowed"),
|
||||
Err(DomainError::HyphenAtLabelEdge) => panic!("invalid domain: label starts or ends with '-'"),
|
||||
Err(DomainError::SingleLabel) => panic!("invalid domain: needs at least two labels (eg. example.com)"),
|
||||
Err(DomainError::NumericTopLevelLabel) => panic!("invalid domain: top-level label is numeric (IP addresses are not domains)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DomainError {
|
||||
Empty,
|
||||
TooLong,
|
||||
EmptyLabel,
|
||||
LabelTooLong,
|
||||
InvalidCharacter,
|
||||
HyphenAtLabelEdge,
|
||||
SingleLabel,
|
||||
NumericTopLevelLabel,
|
||||
}
|
||||
|
||||
pub(crate) const fn check_domain(domain: &str) -> Result<(), DomainError> {
|
||||
let bytes = domain.as_bytes();
|
||||
if bytes.is_empty() {
|
||||
return Err(DomainError::Empty);
|
||||
}
|
||||
if bytes.len() > MAX_DOMAIN_LEN {
|
||||
return Err(DomainError::TooLong);
|
||||
}
|
||||
|
||||
let mut labels = 0;
|
||||
let mut label_start = 0;
|
||||
let mut last_label_start = 0;
|
||||
let mut i = 0;
|
||||
// Walk one past the end so the final label is checked like the others.
|
||||
while i <= bytes.len() {
|
||||
if i == bytes.len() || bytes[i] == b'.' {
|
||||
match check_label(bytes, label_start, i) {
|
||||
Ok(()) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
labels += 1;
|
||||
last_label_start = label_start;
|
||||
label_start = i + 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if labels < 2 {
|
||||
return Err(DomainError::SingleLabel);
|
||||
}
|
||||
if is_all_digits(bytes, last_label_start, bytes.len()) {
|
||||
return Err(DomainError::NumericTopLevelLabel);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks `bytes[start..end]` as a single label.
|
||||
const fn check_label(bytes: &[u8], start: usize, end: usize) -> Result<(), DomainError> {
|
||||
let len = end - start;
|
||||
if len == 0 {
|
||||
return Err(DomainError::EmptyLabel);
|
||||
}
|
||||
if len > MAX_LABEL_LEN {
|
||||
return Err(DomainError::LabelTooLong);
|
||||
}
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
match bytes[i] {
|
||||
b'a'..=b'z' | b'0'..=b'9' | b'-' => {}
|
||||
_ => return Err(DomainError::InvalidCharacter),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if bytes[start] == b'-' || bytes[end - 1] == b'-' {
|
||||
return Err(DomainError::HyphenAtLabelEdge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn is_all_digits(bytes: &[u8], start: usize, end: usize) -> bool {
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
if !bytes[i].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DomainError::*;
|
||||
use super::check_domain;
|
||||
|
||||
mod valid {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apex_domain() {
|
||||
assert_eq!(check_domain("getartcraft.com"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subdomains() {
|
||||
assert_eq!(check_domain("desktop.getartcraft.com"), Ok(()));
|
||||
assert_eq!(check_domain("studio-staging.studio.storyteller.ai"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_and_hyphens_inside_labels() {
|
||||
assert_eq!(check_domain("cdn-2.fakeyou.com"), Ok(()));
|
||||
assert_eq!(check_domain("3d.getartcraft.com"), Ok(()));
|
||||
assert_eq!(check_domain("pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lengths() {
|
||||
let label_63 = "a".repeat(63);
|
||||
assert_eq!(check_domain(&format!("{label_63}.com")), Ok(()));
|
||||
|
||||
// 63 + 1 + 63 + 1 + 63 + 1 + 61 = 253
|
||||
let domain_253 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(61));
|
||||
assert_eq!(domain_253.len(), 253);
|
||||
assert_eq!(check_domain(&domain_253), Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
mod invalid {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
assert_eq!(check_domain(""), Err(Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_long() {
|
||||
let label_63 = "a".repeat(63);
|
||||
let domain_254 = format!("{label_63}.{label_63}.{label_63}.{}", "a".repeat(62));
|
||||
assert_eq!(check_domain(&domain_254), Err(TooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_labels() {
|
||||
assert_eq!(check_domain(".getartcraft.com"), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("getartcraft.com."), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("desktop..getartcraft.com"), Err(EmptyLabel));
|
||||
assert_eq!(check_domain("."), Err(EmptyLabel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_too_long() {
|
||||
assert_eq!(check_domain(&format!("{}.com", "a".repeat(64))), Err(LabelTooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase() {
|
||||
assert_eq!(check_domain("GetArtCraft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_port_path_and_whitespace() {
|
||||
assert_eq!(check_domain("https://getartcraft.com"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain("getartcraft.com:443"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain("getartcraft.com/"), Err(InvalidCharacter));
|
||||
assert_eq!(check_domain(" getartcraft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii() {
|
||||
assert_eq!(check_domain("artcräft.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscore() {
|
||||
assert_eq!(check_domain("dev_proxy.fakeyou.com"), Err(InvalidCharacter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphen_at_label_edge() {
|
||||
assert_eq!(check_domain("-desktop.getartcraft.com"), Err(HyphenAtLabelEdge));
|
||||
assert_eq!(check_domain("desktop-.getartcraft.com"), Err(HyphenAtLabelEdge));
|
||||
assert_eq!(check_domain("getartcraft.com-"), Err(HyphenAtLabelEdge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_label() {
|
||||
assert_eq!(check_domain("localhost"), Err(SingleLabel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_addresses() {
|
||||
assert_eq!(check_domain("127.0.0.1"), Err(NumericTopLevelLabel));
|
||||
assert_eq!(check_domain("example.123"), Err(NumericTopLevelLabel));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ workspace-build-acceleration.workspace = true
|
||||
# Internal
|
||||
actix_artcraft.workspace = true
|
||||
actix_cors_configs = { path = "../../../lib/actix_cors_configs" }
|
||||
domains = { workspace = true, features = ["url"] }
|
||||
actix_helpers.workspace = true
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_api_keys.workspace = true
|
||||
|
||||
@@ -59,7 +59,6 @@ use crate::http_server::endpoints::media_files::get::get_media_file_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_media_files_handler::*;
|
||||
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::*;
|
||||
use crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::*;
|
||||
@@ -379,6 +378,12 @@ use artcraft_api_defs::media_file::list::by_type::list_session_common::*;
|
||||
use artcraft_api_defs::media_file::job::list_media_files_by_job::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::*;
|
||||
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::{
|
||||
ListMediaFilesForUserV2PathInfo, ListMediaFilesForUserV2QueryParams,
|
||||
ListMediaFilesForUserV2SuccessResponse, UserMediaFileV2Pagination,
|
||||
};
|
||||
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
use artcraft_api_defs::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use artcraft_api_defs::media_file::list_session_project_media_files::*;
|
||||
use artcraft_api_defs::media_file::project::upload_updated_mood_board_project::*;
|
||||
use artcraft_api_defs::media_file::project::upload_new_mood_board_project::*;
|
||||
@@ -1142,6 +1147,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
InferTtsSuccessResponse,
|
||||
InferenceJobStatusResponsePayload,
|
||||
InferenceJobTokenType,
|
||||
LegacyMediaFileCoverImageDetails,
|
||||
LegacyQueueDetails,
|
||||
ListActiveUserSubscriptionsResponse,
|
||||
ListAvailableWeightsQuery,
|
||||
|
||||
+42
-29
@@ -1,43 +1,56 @@
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use once_cell::sync::Lazy;
|
||||
use domains::Domain;
|
||||
use domains::fakeyou::{FAKEYOU_CDN, FAKEYOU_DEV_CDN};
|
||||
use server_environment::ServerEnvironment;
|
||||
use url::Url;
|
||||
|
||||
const FAKEYOU_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
const STORYTELLER_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
|
||||
const FAKEYOU_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
const STORYTELLER_DEVELOPMENT_CDN_STR: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
|
||||
|
||||
const FAKEYOU_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const STORYTELLER_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELLER_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const FAKEYOU_DEVELOPMENT_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_DEVELOPMENT_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const STORYTELLER_DEVELOPMENT_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELLER_DEVELOPMENT_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
// NB: Storyteller media is served from the FakeYou CDN too.
|
||||
const FAKEYOU_PRODUCTION: Domain = FAKEYOU_CDN;
|
||||
const STORYTELLER_PRODUCTION: Domain = FAKEYOU_CDN;
|
||||
|
||||
const FAKEYOU_DEVELOPMENT: Domain = FAKEYOU_DEV_CDN;
|
||||
const STORYTELLER_DEVELOPMENT: Domain = FAKEYOU_DEV_CDN;
|
||||
|
||||
// TODO(bt,2025-01-31): Perhaps this should be config driven and configurable at runtime instead of hardcoded.
|
||||
pub fn get_cdn_host(media_domain: MediaDomain, server_environment: ServerEnvironment) -> &'static str {
|
||||
match (media_domain, server_environment) {
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN_STR,
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN_STR,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Development) => STORYTELLER_DEVELOPMENT_CDN_STR,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Production) => STORYTELLER_CDN_STR,
|
||||
}
|
||||
cdn_domain(media_domain, server_environment).https_link_without_path()
|
||||
}
|
||||
|
||||
pub fn new_cdn_url(media_domain: MediaDomain, server_environment: ServerEnvironment) -> Url {
|
||||
cdn_domain(media_domain, server_environment).https_url().clone()
|
||||
}
|
||||
|
||||
fn cdn_domain(media_domain: MediaDomain, server_environment: ServerEnvironment) -> Domain {
|
||||
match (media_domain, server_environment) {
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT_CDN.clone(),
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_CDN.clone(),
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Development) => STORYTELLER_DEVELOPMENT_CDN.clone(),
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Production) => STORYTELLER_CDN.clone(),
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Development) => FAKEYOU_DEVELOPMENT,
|
||||
(MediaDomain::FakeYou, ServerEnvironment::Production) => FAKEYOU_PRODUCTION,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Development) => STORYTELLER_DEVELOPMENT,
|
||||
(MediaDomain::Storyteller, ServerEnvironment::Production) => STORYTELLER_PRODUCTION,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::http_server::common_responses::media::cdn_link::{get_cdn_host, new_cdn_url};
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
const PROD_CDN: &str = "https://cdn-2.fakeyou.com";
|
||||
const DEV_CDN: &str = "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev";
|
||||
|
||||
#[test]
|
||||
fn cdn_hosts() {
|
||||
assert_eq!(get_cdn_host(MediaDomain::FakeYou, ServerEnvironment::Production), PROD_CDN);
|
||||
assert_eq!(get_cdn_host(MediaDomain::Storyteller, ServerEnvironment::Production), PROD_CDN);
|
||||
assert_eq!(get_cdn_host(MediaDomain::FakeYou, ServerEnvironment::Development), DEV_CDN);
|
||||
assert_eq!(get_cdn_host(MediaDomain::Storyteller, ServerEnvironment::Development), DEV_CDN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn_urls() {
|
||||
assert_eq!(new_cdn_url(MediaDomain::FakeYou, ServerEnvironment::Production).as_str(), "https://cdn-2.fakeyou.com/");
|
||||
assert_eq!(new_cdn_url(MediaDomain::Storyteller, ServerEnvironment::Production).as_str(), "https://cdn-2.fakeyou.com/");
|
||||
assert_eq!(new_cdn_url(MediaDomain::FakeYou, ServerEnvironment::Development).as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
|
||||
assert_eq!(new_cdn_url(MediaDomain::Storyteller, ServerEnvironment::Development).as_str(), "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/");
|
||||
}
|
||||
}
|
||||
+4
-16
@@ -1,17 +1,6 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use domains::fakeyou::FAKEYOU_CDN;
|
||||
use url::Url;
|
||||
|
||||
//const FAKEYOU_CDN_STR: &str = "https://storage.googleapis.com/dev-vocodes-public";
|
||||
const FAKEYOU_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
|
||||
const STORYTELLER_CDN_STR: &str = "https://cdn-2.fakeyou.com";
|
||||
|
||||
const FAKEYOU_CDN: Lazy<Url> = Lazy::new(|| Url::parse(FAKEYOU_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
const STORYTELLER_CDN: Lazy<Url> = Lazy::new(|| Url::parse(STORYTELLER_CDN_STR)
|
||||
.expect("should never fail"));
|
||||
|
||||
/// Which domain to generate CDN, etc. links for.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum MediaDomain {
|
||||
@@ -22,14 +11,13 @@ pub enum MediaDomain {
|
||||
impl MediaDomain {
|
||||
pub fn new_cdn_url(&self) -> Url {
|
||||
match self {
|
||||
MediaDomain::FakeYou => FAKEYOU_CDN.clone(),
|
||||
MediaDomain::Storyteller => STORYTELLER_CDN.clone(),
|
||||
// NB: Storyteller media is served from the FakeYou CDN too.
|
||||
MediaDomain::FakeYou | MediaDomain::Storyteller => FAKEYOU_CDN.https_url().clone(),
|
||||
}
|
||||
}
|
||||
pub fn cdn_url_str(&self) -> &'static str {
|
||||
match self {
|
||||
MediaDomain::FakeYou => FAKEYOU_CDN_STR,
|
||||
MediaDomain::Storyteller => STORYTELLER_CDN_STR,
|
||||
MediaDomain::FakeYou | MediaDomain::Storyteller => FAKEYOU_CDN.https_link_without_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
|
||||
use actix_web::HttpRequest;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use domains::artcraft::{ARTCRAFT_WEBAPP, ARTCRAFT_WEBSITE, ARTCRAFT_WEBSITE_WWW};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use rand::{
|
||||
rngs::{OsRng, StdRng},
|
||||
@@ -16,7 +17,11 @@ use sha2::{Digest, Sha256};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
const CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
|
||||
const APPROVAL_ORIGINS: &[&str] = &["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"];
|
||||
const APPROVAL_ORIGINS: &[&str] = &[
|
||||
ARTCRAFT_WEBAPP.https_link_without_path(),
|
||||
ARTCRAFT_WEBSITE.https_link_without_path(),
|
||||
ARTCRAFT_WEBSITE_WWW.https_link_without_path(),
|
||||
];
|
||||
const WINDOW: Duration = Duration::from_secs(60);
|
||||
const MAX_BUCKETS: usize = 10_000;
|
||||
|
||||
|
||||
+2
-104
@@ -1,33 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path, Query};
|
||||
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
|
||||
use artcraft_api_defs::common::responses::media_links::MediaLinks;
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
|
||||
use enums::by_table::media_files::media_file_class::MediaFileClass;
|
||||
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
use enums::common::view_as::ViewAs;
|
||||
use enums::common::visibility::Visibility;
|
||||
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use log::info;
|
||||
use std::time::Instant;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::count_media_files_for_user::{count_media_files_for_user, CountMediaFilesForUserArgs};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::http_server::common_responses::pagination_page::PaginationPage;
|
||||
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
|
||||
@@ -90,93 +75,6 @@ pub struct ListMediaFilesForUserSuccessResponse {
|
||||
pub pagination: PaginationPage,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MediaFileForUserListItem {
|
||||
pub token: MediaFileToken,
|
||||
|
||||
/// The coarse-grained class of media file: image, video, etc.
|
||||
pub media_class: MediaFileClass,
|
||||
|
||||
/// Type of media will dictate which fields are populated and what
|
||||
/// the frontend should display (eg. video player vs audio player).
|
||||
/// This is closer in meaning to a "mime type".
|
||||
pub media_type: MediaFileType,
|
||||
|
||||
/// If this is an engine/3D asset, this is the broad category (scene,
|
||||
/// animation, etc.) of that object.
|
||||
/// This can also be used for filtering in list/batch endpoints.
|
||||
pub maybe_engine_category: Option<MediaFileEngineCategory>,
|
||||
|
||||
/// If this is an engine/3D asset for an animation or a rig that can
|
||||
/// be animated with either (or both) skeletal or blend shape animations,
|
||||
/// this describes the animation regime used or supported.
|
||||
pub maybe_animation_type: Option<MediaFileAnimationType>,
|
||||
|
||||
/// Details where the media file came from.
|
||||
pub origin: MediaFileOriginDetails,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_category: MediaFileOriginCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub origin_product_category: MediaFileOriginProductCategory,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
|
||||
|
||||
#[deprecated(note="Use MediaFileOriginDetails instead")]
|
||||
pub maybe_origin_model_token: Option<String>,
|
||||
|
||||
/// (DEPRECATED) URL path to the media file
|
||||
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_path: String,
|
||||
|
||||
/// (DEPRECATED) Full URL to the media file
|
||||
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
|
||||
pub public_bucket_url: String,
|
||||
|
||||
/// Rich CDN links to the media, including thumbnails, previews, and more.
|
||||
pub media_links: MediaLinks,
|
||||
|
||||
/// The foreign key to the prompt used to generate the media, if applicable.
|
||||
pub maybe_prompt_token: Option<PromptToken>,
|
||||
|
||||
/// Information about the cover image. Many media files do not require a cover image,
|
||||
/// e.g. image files, video files with thumbnails, audio files, etc.
|
||||
/// 3D files require them.
|
||||
pub cover_image: MediaFileCoverImageDetails,
|
||||
|
||||
pub creator_set_visibility: Visibility,
|
||||
|
||||
/// The file was uploaded by the user.
|
||||
/// This does not include files generated on the client side, like studio renders.
|
||||
pub is_user_upload: bool,
|
||||
|
||||
/// The file was created by the system.
|
||||
/// This includes files generated on the client side, like studio renders.
|
||||
pub is_intermediate_system_file: bool,
|
||||
|
||||
/// The name or title of the media file (optional)
|
||||
pub maybe_title: Option<String>,
|
||||
|
||||
/// Text transcripts for TTS, etc.
|
||||
pub maybe_text_transcript: Option<String>,
|
||||
|
||||
/// For Comfy / Video Style Transfer jobs, this might include
|
||||
/// the name of the selected style.
|
||||
pub maybe_style_name: Option<StyleTransferName>,
|
||||
|
||||
/// Duration for audio and video files, if available.
|
||||
/// Measured in milliseconds.
|
||||
pub maybe_duration_millis: Option<u64>,
|
||||
|
||||
/// Statistics about the media file
|
||||
pub stats: SimpleEntityStats,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List all of a user's media files (paginated).
|
||||
///
|
||||
/// This endpoint uses the session to automatically show all files for the given user, but only
|
||||
|
||||
+4
-63
@@ -3,15 +3,17 @@ use std::time::Instant;
|
||||
|
||||
use actix_web::web::{Json, Path, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::{
|
||||
ListMediaFilesForUserV2PathInfo, ListMediaFilesForUserV2QueryParams,
|
||||
ListMediaFilesForUserV2SuccessResponse, UserMediaFileV2Pagination,
|
||||
};
|
||||
use enums::common::view_as::ViewAs;
|
||||
use log::info;
|
||||
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
|
||||
list_media_files_for_user, ListMediaFileForUserArgs,
|
||||
};
|
||||
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use super::list_media_files_for_user_handler::MediaFileForUserListItem;
|
||||
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
@@ -21,67 +23,6 @@ use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types:
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::allowed_studio_access::allowed_studio_access;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2PathInfo {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMediaFilesForUserV2QueryParams {
|
||||
pub sort_ascending: Option<bool>,
|
||||
pub page_size: Option<usize>,
|
||||
pub page_index: Option<usize>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileClass`,
|
||||
/// which are the broad category of media files: image, video, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_classes=audio`
|
||||
/// - `?filter_media_classes=image,video`
|
||||
/// - `?filter_media_classes=dimensional`
|
||||
/// - etc.
|
||||
pub filter_media_classes: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileType`,
|
||||
/// which are mimetype-like / format-like categories of media files: glb, gltf,
|
||||
/// scene_json, jpg, png, mp4, wav, etc.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_media_type=scene_json`
|
||||
/// - `?filter_media_type=glb,gltf,fbx`
|
||||
/// - `?filter_media_type=pmd,vmd,pmx`
|
||||
/// - `?filter_media_type=jpg,png,gif`
|
||||
/// - `?filter_media_type=wav,mp3`
|
||||
/// - `?filter_media_type=mp4`
|
||||
/// - etc.
|
||||
pub filter_media_type: Option<String>,
|
||||
|
||||
/// NB: This can be one (or more comma-separated values) from `MediaFileEngineCategory`.
|
||||
///
|
||||
/// Usage:
|
||||
/// - `?filter_engine_categories=scene`
|
||||
/// - `?filter_engine_categories=animation,character,object`
|
||||
/// - etc.
|
||||
pub filter_engine_categories: Option<String>,
|
||||
|
||||
/// Include user uploaded files in the results.
|
||||
/// By default, we do not return them unless this flag is set to true.
|
||||
pub include_user_uploads: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListMediaFilesForUserV2SuccessResponse {
|
||||
pub success: bool,
|
||||
pub results: Vec<MediaFileForUserListItem>,
|
||||
pub pagination: UserMediaFileV2Pagination,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UserMediaFileV2Pagination {
|
||||
pub current: usize,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Faster user-media listing for infinite scroll.
|
||||
///
|
||||
/// Unlike `/v1/media_files/list/user/{username}`, this endpoint skips the exact
|
||||
|
||||
+96
-7
@@ -1,14 +1,20 @@
|
||||
use artcraft_api_defs::common::responses::cover_image_links::CoverImageLinks;
|
||||
use artcraft_api_defs::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
|
||||
use artcraft_api_defs::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
|
||||
use artcraft_api_defs::common::responses::media_file_origin_details::{
|
||||
MediaFileModelDetails as MediaFileModelDetailsApi, MediaFileOriginDetails as MediaFileOriginDetailsApi,
|
||||
};
|
||||
use artcraft_api_defs::common::responses::simple_entity_stats::SimpleEntityStats;
|
||||
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use mysql_queries::queries::media_files::list::user_media_file_list_item::MediaFileListItem;
|
||||
use server_environment::ServerEnvironment;
|
||||
|
||||
use super::list_media_files_for_user_handler::MediaFileForUserListItem;
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
|
||||
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
|
||||
use crate::http_server::common_responses::media_file_origin_details::{MediaFileModelDetails, MediaFileOriginDetails};
|
||||
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
|
||||
|
||||
pub(super) struct UserMediaFileResponseArgs {
|
||||
@@ -62,13 +68,13 @@ pub(super) fn build_user_media_file_response(args: UserMediaFileResponseArgs) ->
|
||||
media_type: record.media_type,
|
||||
maybe_engine_category: record.maybe_engine_category,
|
||||
maybe_animation_type: record.maybe_animation_type,
|
||||
origin: MediaFileOriginDetails::from_db_fields_str(
|
||||
origin: into_api_origin(MediaFileOriginDetails::from_db_fields_str(
|
||||
record.origin_category,
|
||||
record.origin_product_category,
|
||||
record.maybe_origin_model_type,
|
||||
record.maybe_origin_model_token.as_deref(),
|
||||
record.maybe_origin_model_title.as_deref(),
|
||||
),
|
||||
)),
|
||||
origin_category: record.origin_category,
|
||||
origin_product_category: record.origin_product_category,
|
||||
maybe_origin_model_type: record.maybe_origin_model_type,
|
||||
@@ -77,14 +83,14 @@ pub(super) fn build_user_media_file_response(args: UserMediaFileResponseArgs) ->
|
||||
maybe_prompt_token: record.maybe_prompt_token,
|
||||
public_bucket_path: public_bucket_path.get_full_object_path_str().to_string(),
|
||||
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_environment),
|
||||
cover_image: MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
cover_image: into_api_cover_image(MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&record.token,
|
||||
media_domain,
|
||||
server_environment,
|
||||
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
|
||||
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
|
||||
),
|
||||
)),
|
||||
creator_set_visibility: record.creator_set_visibility,
|
||||
is_user_upload: record.is_user_upload,
|
||||
is_intermediate_system_file: record.is_intermediate_system_file,
|
||||
@@ -106,3 +112,86 @@ pub(super) fn build_user_media_file_response(args: UserMediaFileResponseArgs) ->
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn into_api_origin(origin: MediaFileOriginDetails) -> MediaFileOriginDetailsApi {
|
||||
MediaFileOriginDetailsApi {
|
||||
origin_category: origin.origin_category,
|
||||
product_category: origin.product_category,
|
||||
maybe_model: origin.maybe_model.map(|model| match model {
|
||||
MediaFileModelDetails::SystemModel { model_type } => MediaFileModelDetailsApi::SystemModel { model_type },
|
||||
MediaFileModelDetails::ModelWeight { model_type, token, title } => {
|
||||
MediaFileModelDetailsApi::ModelWeight { model_type, token, title }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_api_cover_image(cover: MediaFileCoverImageDetails) -> LegacyMediaFileCoverImageDetails {
|
||||
LegacyMediaFileCoverImageDetails {
|
||||
maybe_cover_image_public_bucket_path: cover.maybe_cover_image_public_bucket_path,
|
||||
maybe_cover_image_public_bucket_url: cover.maybe_cover_image_public_bucket_url,
|
||||
maybe_links: cover.maybe_links.map(|links| CoverImageLinks {
|
||||
cdn_url: links.cdn_url,
|
||||
thumbnail_template: links.thumbnail_template,
|
||||
}),
|
||||
default_cover: MediaFileDefaultCover {
|
||||
image_index: cover.default_cover.image_index,
|
||||
color_index: cover.default_cover.color_index,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
|
||||
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
|
||||
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod conversions {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn origin_details_preserve_system_and_user_models() {
|
||||
for (model_type, model_token, model_title) in [
|
||||
(None, None, None),
|
||||
(Some(MediaFileOriginModelType::Tacotron2), None, None),
|
||||
(Some(MediaFileOriginModelType::Tacotron2), Some("weight_fixture"), Some("Fixture model")),
|
||||
] {
|
||||
let origin = MediaFileOriginDetails::from_db_fields_str(
|
||||
MediaFileOriginCategory::Upload,
|
||||
MediaFileOriginProductCategory::Unknown,
|
||||
model_type,
|
||||
model_token,
|
||||
model_title,
|
||||
);
|
||||
let original = serde_json::to_value(&origin).unwrap();
|
||||
let converted = serde_json::to_value(into_api_origin(origin)).unwrap();
|
||||
assert_eq!(converted, original);
|
||||
let decoded: MediaFileOriginDetailsApi = serde_json::from_value(converted).unwrap();
|
||||
assert_eq!(serde_json::to_value(decoded).unwrap(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_details_preserve_legacy_fields_and_cdn_links() {
|
||||
for maybe_hash in [None, Some("fixture_cover_hash")] {
|
||||
let cover = MediaFileCoverImageDetails::from_optional_db_fields(
|
||||
&MediaFileToken::new_from_str("m_fixture"),
|
||||
MediaDomain::FakeYou,
|
||||
ServerEnvironment::Production,
|
||||
maybe_hash,
|
||||
Some("image_"),
|
||||
Some(".png"),
|
||||
);
|
||||
let original = serde_json::to_value(&cover).unwrap();
|
||||
let converted = serde_json::to_value(into_api_cover_image(cover)).unwrap();
|
||||
assert_eq!(converted, original);
|
||||
let decoded: LegacyMediaFileCoverImageDetails = serde_json::from_value(converted).unwrap();
|
||||
assert_eq!(serde_json::to_value(decoded).unwrap(), original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -7,6 +7,7 @@ use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{Service, ServiceResponse};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App};
|
||||
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::ListMediaFilesForUserV2SuccessResponse;
|
||||
use mysql_testing::fixtures::media_files::create_test_video_media_file;
|
||||
use mysql_testing::fixtures::users::create_test_user;
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
@@ -108,6 +109,11 @@ async fn user_media_endpoint_versions_preserve_legacy_contract() {
|
||||
assert_eq!(first_v2.as_object().unwrap().len(), 3);
|
||||
assert_eq!(first_v2["results"], legacy["results"]);
|
||||
assert_eq!(first_v2["pagination"], json!({"current": 0, "has_more": true}));
|
||||
// A Rust API client can decode and re-encode the full response without
|
||||
// dropping nested or deprecated fields from either endpoint's media items.
|
||||
let typed_v2: ListMediaFilesForUserV2SuccessResponse = serde_json::from_value(first_v2.clone()).unwrap();
|
||||
assert_eq!(typed_v2.results.len(), 25);
|
||||
assert_eq!(serde_json::to_value(typed_v2).unwrap(), first_v2);
|
||||
let (_, last_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=1"), session).await;
|
||||
assert_eq!(last_v2["results"], last["results"]);
|
||||
assert_eq!(last_v2["pagination"], json!({"current": 1, "has_more": false}));
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ArtCraft Desktop — Open Source AI for Creators</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="ArtCraft Desktop is the best Open Source AI tool for creators. Generate AI images and video with real control, right on your desktop."
|
||||
/>
|
||||
<link rel="canonical" href="https://desktop.getartcraft.com/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://desktop.getartcraft.com/" />
|
||||
<meta property="og:site_name" content="ArtCraft" />
|
||||
<meta property="og:title" content="ArtCraft Desktop" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="ArtCraft Desktop is the best Open Source AI tool for creators."
|
||||
/>
|
||||
<meta property="og:image" content="https://desktop.getartcraft.com/images/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f1ee" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b0b0c" />
|
||||
<link rel="icon" href="/artcraft-icon.svg" type="image/svg+xml" />
|
||||
|
||||
<!-- Same type system as getartcraft.com: Archivo (with its width axis,
|
||||
for the stretched wordmark), Instrument Serif italic accents, Inter
|
||||
body, Geist Mono HUD labels. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Geist+Mono:wght@400..600&family=Instrument+Serif:ital@0;1&family=Inter:wght@400..700&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="site-nav">
|
||||
<a class="nav-logo" href="https://getartcraft.com/" aria-label="ArtCraft home">
|
||||
<svg viewBox="0 0 116.34 97.5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a class="nav-link hud-label" href="https://getartcraft.com/download"><span>Download</span></a>
|
||||
<a class="nav-link hud-label" href="https://github.com/storytold/artcraft"><span>GitHub</span></a>
|
||||
<a class="nav-cta hud-label" href="https://getartcraft.com/">Go to ArtCraft</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section class="shell">
|
||||
<div class="rail">
|
||||
<span class="tick tick-half" style="top: -5px; left: -6px" aria-hidden="true"></span>
|
||||
<span class="tick tick-half" style="top: -5px; right: -6px" aria-hidden="true"></span>
|
||||
|
||||
<div class="eyebrow">
|
||||
<p class="hud-label muted"><span class="faint">01</span><span class="faint slash" aria-hidden="true">/</span>Desktop</p>
|
||||
<p class="hud-label faint hide-sm">macOS · Windows · Open source</p>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<!-- The wordmark: the brand mark stands in for the leading A,
|
||||
set in Archivo at the poster width like the main site. -->
|
||||
<div class="wordmark-wrap">
|
||||
<div class="wordmark" role="img" aria-label="ArtCraft">
|
||||
<svg class="wordmark-logo" viewBox="0 0 116.34 97.5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z" />
|
||||
</svg><span aria-hidden="true">RTCRAFT</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hud-label accent-ink wordmark-sub">Desktop</p>
|
||||
|
||||
<h1 class="headline">
|
||||
ArtCraft Desktop is the best <em>Open Source</em> AI tool for
|
||||
creators
|
||||
</h1>
|
||||
|
||||
<div class="ctas">
|
||||
<a class="btn btn-primary" href="https://getartcraft.com/download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 17V3" /><path d="m6 11 6 6 6-6" /><path d="M19 21H5" />
|
||||
</svg>
|
||||
Go to downloads
|
||||
</a>
|
||||
<a class="btn btn-secondary" href="https://getartcraft.com/">
|
||||
Go to ArtCraft
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<p class="hud-label faint meta">Free · Open source · macOS & Windows</p>
|
||||
</div>
|
||||
|
||||
<figure class="shot">
|
||||
<div class="shot-bar">
|
||||
<figcaption class="hud-label faint">Viewport — 3D scene editor</figcaption>
|
||||
<p class="hud-label faint">ArtCraft Desktop</p>
|
||||
</div>
|
||||
<div class="shot-frame">
|
||||
<img
|
||||
src="/images/artcraft-desktop-screenshot.jpg"
|
||||
alt="Screenshot of the ArtCraft Desktop app showing the 3D scene editor"
|
||||
width="1920"
|
||||
height="945"
|
||||
/>
|
||||
<span class="tick" style="top: 8px; left: 8px" aria-hidden="true"></span>
|
||||
<span class="tick" style="top: 8px; right: 8px" aria-hidden="true"></span>
|
||||
<span class="tick" style="bottom: 8px; left: 8px" aria-hidden="true"></span>
|
||||
<span class="tick" style="bottom: 8px; right: 8px" aria-hidden="true"></span>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell">
|
||||
<div class="rail">
|
||||
<span class="tick tick-half" style="top: -5px; left: -6px" aria-hidden="true"></span>
|
||||
<span class="tick tick-half" style="top: -5px; right: -6px" aria-hidden="true"></span>
|
||||
|
||||
<div class="eyebrow">
|
||||
<p class="hud-label muted"><span class="faint">02</span><span class="faint slash" aria-hidden="true">/</span>Community</p>
|
||||
<p class="hud-label faint hide-sm">Built in the open</p>
|
||||
</div>
|
||||
|
||||
<ul class="socials">
|
||||
<li>
|
||||
<a class="social invert-block" href="https://github.com/storytold/artcraft" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>
|
||||
<span class="hud-label">GitHub</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://discord.gg/artcraft" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" /></svg>
|
||||
<span class="hud-label">Discord</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://www.youtube.com/@OfficialArtCraftStudios" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /></svg>
|
||||
<span class="hud-label">YouTube</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://www.instagram.com/get_artcraft" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="20" x="2" y="2" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" x2="17.51" y1="6.5" y2="6.5" /></svg>
|
||||
<span class="hud-label">Instagram</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="social invert-block" href="https://www.tiktok.com/@artcraft.studios" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" /></svg>
|
||||
<span class="hud-label">TikTok</span>
|
||||
<span class="social-arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="shell">
|
||||
<div class="rail footer-bar">
|
||||
<p class="hud-label faint">© <span id="year">2026</span> ArtCraft</p>
|
||||
<a class="hud-label faint footer-mail" href="mailto:hello@storyteller.ai">hello@storyteller.ai</a>
|
||||
<p class="hud-label faint">Made by artists, for artists</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.getElementById('year').textContent = String(new Date().getFullYear());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
# Netlify config for desktop.getartcraft.com (Nx + Vite static page).
|
||||
#
|
||||
# Netlify settings used to discover this file:
|
||||
# Base directory: frontend
|
||||
# Package directory: apps/artcraft-desktop-website (relative to the base directory)
|
||||
# Manage the build command and publish directory here; leave those UI fields empty.
|
||||
# Paths below are relative to the frontend workspace.
|
||||
|
||||
[build]
|
||||
command = "bash ./apps/artcraft-desktop-website/script/netlify_build.sh"
|
||||
publish = "apps/artcraft-desktop-website/dist/"
|
||||
|
||||
# Vite emits content-hashed files under dist/assets — safe to cache forever.
|
||||
[[headers]]
|
||||
for = "/assets/*"
|
||||
|
||||
[headers.values]
|
||||
"Cache-Control" = "public, max-age=31536000, immutable"
|
||||
|
||||
# Single-page site: unknown paths land on the page instead of a 404.
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@frontend/artcraft-desktop-website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #4e7bfb;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 483 B |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Runs from the Netlify base directory (= the Nx workspace root, `frontend/`).
|
||||
|
||||
echo "Building artcraft-desktop-website"
|
||||
npx nx build @frontend/artcraft-desktop-website
|
||||
|
||||
echo "Final build files:"
|
||||
find apps/artcraft-desktop-website/dist/
|
||||
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
* desktop.getartcraft.com — ported from the getartcraft.com design tokens
|
||||
* (apps/artcraft-website-next/src/app/globals.css). Same language: hairline
|
||||
* rules, hard edges, mono HUD labels, a single blue accent, light "blueprint"
|
||||
* and dark "viewport" themes following the system preference.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f2f1ee;
|
||||
--bg-raised: #faf9f7;
|
||||
--bg-sunken: #e9e8e4;
|
||||
--ink: #101014;
|
||||
--ink-strong: #000000;
|
||||
--muted: rgba(16, 16, 20, 0.62);
|
||||
--faint: rgba(16, 16, 20, 0.42);
|
||||
--line: rgba(16, 16, 20, 0.16);
|
||||
--line-strong: rgba(16, 16, 20, 0.4);
|
||||
--accent: #2d81ff;
|
||||
--accent-ink: #1659c4;
|
||||
--invert-bg: #101014;
|
||||
--invert-fg: #f2f1ee;
|
||||
|
||||
--font-display: 'Archivo', system-ui, sans-serif;
|
||||
--font-serif: 'Instrument Serif', Georgia, serif;
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--rail: 1280px;
|
||||
--pad: 24px;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #121316;
|
||||
--bg-raised: #101014;
|
||||
--bg-sunken: #060607;
|
||||
--ink: #f2f1ee;
|
||||
--ink-strong: #ffffff;
|
||||
--muted: rgba(242, 241, 238, 0.6);
|
||||
--faint: rgba(242, 241, 238, 0.38);
|
||||
--line: rgba(242, 241, 238, 0.15);
|
||||
--line-strong: rgba(242, 241, 238, 0.4);
|
||||
--accent-ink: #74aaff;
|
||||
--invert-bg: #ffffff;
|
||||
--invert-fg: #0b0b0c;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--pad: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
figure,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Micro vocabulary ---------- */
|
||||
|
||||
.hud-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.faint {
|
||||
color: var(--faint);
|
||||
}
|
||||
.accent-ink {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.slash {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Crosshair tick straddling a grid intersection. */
|
||||
.tick {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.tick::before,
|
||||
.tick::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
.tick::before {
|
||||
left: 5px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
.tick::after {
|
||||
top: 5px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
/* Half tick for marks sitting on a top edge. */
|
||||
.tick-half::before {
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
/* Signature interaction: blocks invert on hover. */
|
||||
.invert-block {
|
||||
transition:
|
||||
background-color 120ms linear,
|
||||
color 120ms linear;
|
||||
}
|
||||
.invert-block:hover {
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
|
||||
/* ---------- Nav ---------- */
|
||||
|
||||
.site-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg) 95%, transparent);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
border-right: 1px solid var(--line);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.nav-logo:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.nav-logo svg {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nav-link span {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.nav-link:hover span {
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.nav-link {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
padding: 0 20px;
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
font-weight: 700;
|
||||
}
|
||||
.nav-cta:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ---------- Rails ---------- */
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rail {
|
||||
position: relative;
|
||||
max-width: var(--rail);
|
||||
margin: 0 auto;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px var(--pad);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.hide-sm {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.hide-sm {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
|
||||
.hero {
|
||||
padding: 56px var(--pad) 64px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.hero {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Poster wordmark: Archivo at the poster width (wdth 125) with the logo-A
|
||||
standing in for the first letter — the getartcraft.com hero setting.
|
||||
The word's advance run measures ~7.0em at this cut, so sizing the font
|
||||
from the container width justifies it flush across the rail. */
|
||||
.wordmark-wrap {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: var(--ink-strong);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 750;
|
||||
font-stretch: 125%;
|
||||
font-size: calc(100cqw / 7.05);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
animation: fade-up 0.7s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
.wordmark-logo {
|
||||
flex: none;
|
||||
width: calc(0.7em * 116.34 / 97.5);
|
||||
height: 0.7em;
|
||||
margin-right: 0.05em;
|
||||
align-self: baseline;
|
||||
transform: translateY(0.006em);
|
||||
}
|
||||
|
||||
.wordmark-sub {
|
||||
margin-top: 16px;
|
||||
animation: fade-up 0.7s 0.08s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.headline {
|
||||
max-width: 48rem;
|
||||
margin-top: 40px;
|
||||
color: var(--ink-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.25rem;
|
||||
font-weight: 620;
|
||||
font-stretch: 118%;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.035em;
|
||||
animation: fade-up 0.7s 0.16s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
.headline em {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-stretch: normal;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.headline {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.headline {
|
||||
font-size: 3.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
animation: fade-up 0.7s 0.24s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 150ms linear,
|
||||
color 150ms linear,
|
||||
border-color 150ms linear,
|
||||
opacity 150ms linear;
|
||||
}
|
||||
.btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
border-color: transparent;
|
||||
background: var(--invert-bg);
|
||||
color: var(--invert-fg);
|
||||
}
|
||||
|
||||
/* ---------- Screenshot ---------- */
|
||||
|
||||
.shot {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.shot-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px var(--pad);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.shot-frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
.shot-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.shot-frame .tick {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ---------- Socials ---------- */
|
||||
|
||||
.socials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--line);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.socials {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
/* Odd count on the 2-up grid: the last cell spans the row. */
|
||||
.socials li:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.socials li:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.social {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 24px var(--pad);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.social {
|
||||
padding: 32px 28px;
|
||||
}
|
||||
}
|
||||
.social svg {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.social-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--faint);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.social:hover .social-arrow {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
|
||||
.footer-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px var(--pad);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.footer-bar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.footer-mail:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Motion ---------- */
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Static landing page for desktop.getartcraft.com. No framework: index.html
|
||||
// plus one stylesheet, so Vite is only here for dev serving and hashed
|
||||
// production assets.
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/artcraft-desktop-website',
|
||||
server: {
|
||||
port: 4260,
|
||||
host: 'localhost',
|
||||
},
|
||||
preview: {
|
||||
port: 4360,
|
||||
host: 'localhost',
|
||||
},
|
||||
build: {
|
||||
outDir: './dist',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user