Compare commits

..
Author SHA1 Message Date
Brandon Thomas 6b34f23b43 tests 2026-09-27 22:55:36 -04:00
Brandon Thomas 7a88118349 fold cdn configs in 2026-09-27 22:41:01 -04:00
Brandon Thomas e07bf35cd0 static config for first party domains 2026-09-27 22:40:14 -04:00
Brandon Thomas a1147a98d2 Merge pull request #40 from storytold/bt-desktop-site
add desktop marketing website
2026-09-27 22:12:11 -04:00
Brandon Thomas f67fb7db31 add desktop marketing website 2026-09-27 22:10:27 -04:00
Brandon Thomas 7d4e89ced9 Merge pull request #39 from storytold/bt-query-perf-2
fixes to new endpoint
2026-09-27 22:01:25 -04:00
Brandon Thomas b9ee1bd225 fixes to new endpoint 2026-09-27 22:01:01 -04:00
Brandon Thomas 68886b62fe Merge pull request #37 from storytold/bt-query-perf
more performant list user files endpoint
2026-09-27 20:22:51 -04:00
Brandon Thomas 35a859529f Merge pull request #38 from storytold/bt-tauri-cors
cors for tauri localhost + tauri desktop
2026-09-27 17:28:25 -04:00
Brandon Thomas 3a93e358cd tests 2026-09-27 17:27:40 -04:00
Brandon Thomas 1cb0b32898 move desktop configs 2026-09-27 17:25:20 -04:00
Brandon Thomas c50bb78c32 tauri cors for new domain 2026-09-27 17:12:27 -04:00
57 changed files with 2716 additions and 249 deletions

No files matched your search

+1
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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" }
@@ -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,
}
@@ -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;
@@ -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,
@@ -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;
@@ -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>,
}
+1
View File
@@ -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);
+3
View File
@@ -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))
+19
View File
@@ -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 }
+69
View File
@@ -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/");
}
}
+163
View File
@@ -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());
}
}
}
+77
View File
@@ -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/");
}
}
}
+21
View File
@@ -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;
+77
View File
@@ -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");
}
}
+25
View File
@@ -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))
})
}
+217
View File
@@ -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,
@@ -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/");
}
}
@@ -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,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;
@@ -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
@@ -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
@@ -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);
}
}
}
}
@@ -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 &amp; 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,
},
}));