Skip to content

Commit 38f3294

Browse files
authored
Merge pull request #2453 from AtCoder-NoviSteps/#2446
🐛 Try to fix for "SchemaError: No shape could be created for schema" (#2446)
2 parents 325d23c + 2f47b5c commit 38f3294

File tree

7 files changed

+986
-91
lines changed

7 files changed

+986
-91
lines changed

src/lib/types/auth_forms.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Defines validation constraints that can be applied to form fields.
3+
*
4+
* @interface FieldConstraints
5+
* @property {number} [minlength] - Minimum number of characters required for the field value
6+
* @property {number} [maxlength] - Maximum number of characters allowed for the field value
7+
* @property {boolean} [required] - Whether the field is mandatory and must have a value
8+
* @property {string} [pattern] - Regular expression pattern that the field value must match
9+
*/
10+
export type FieldConstraints = {
11+
minlength?: number;
12+
maxlength?: number;
13+
required?: boolean;
14+
pattern?: string;
15+
};
16+
17+
export type AuthFormConstraints = {
18+
username?: FieldConstraints;
19+
password?: FieldConstraints;
20+
};
21+
22+
/**
23+
* Represents the state and data structure for authentication forms.
24+
*
25+
* @interface AuthForm
26+
* @property {string} id - Unique identifier for the form instance
27+
* @property {boolean} valid - Indicates whether the form data passes validation
28+
* @property {boolean} posted - Indicates whether the form has been submitted
29+
* @property {Object} data - The form input data
30+
* @property {string} data.username - The username field value
31+
* @property {string} data.password - The password field value
32+
* @property {Record<string, string[]>} errors - Collection of validation errors keyed by field name
33+
* @property {AuthFormConstraints} [constraints] - Optional validation constraints for the form
34+
* @property {Record<string, unknown>} [shape] - Optional form schema or structure definition
35+
* @property {string} message - General message associated with the form (success, error, etc.)
36+
*/
37+
export type AuthForm = {
38+
id: string;
39+
valid: boolean;
40+
posted: boolean;
41+
data: { username: string; password: string };
42+
errors: Record<string, string[]>;
43+
constraints?: AuthFormConstraints;
44+
shape?: Record<string, unknown>;
45+
message: string;
46+
};
47+
48+
/**
49+
* Represents a strategy for creating authentication forms.
50+
*
51+
* @interface AuthFormCreationStrategy
52+
* @property {string} name - The unique identifier or display name for this creation strategy
53+
* @property {() => Promise<{ form: AuthForm }>} run - Asynchronous function that executes the strategy and returns the created authentication form
54+
*/
55+
export type AuthFormCreationStrategy = {
56+
name: string;
57+
run: () => Promise<{ form: AuthForm }>;
58+
};
59+
60+
export type AuthFormCreationStrategies = AuthFormCreationStrategy[];
61+
62+
/**
63+
* Defines a validation strategy for authentication forms.
64+
*
65+
* A validation strategy encapsulates the logic for validating authentication
66+
* form data from HTTP requests and returning the processed form object.
67+
*
68+
* @example
69+
* ```typescript
70+
* const loginStrategy: AuthFormValidationStrategy = {
71+
* name: 'login',
72+
* run: async (request) => {
73+
* // Validation logic here
74+
* return { form: validatedForm };
75+
* }
76+
* };
77+
* ```
78+
*/
79+
export type AuthFormValidationStrategy = {
80+
name: string;
81+
run: (request: Request) => Promise<{ form: AuthForm }>;
82+
};
83+
84+
export type AuthFormValidationStrategies = AuthFormValidationStrategy[];

src/lib/utils/auth_forms.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { redirect } from '@sveltejs/kit';
2+
3+
import { superValidate } from 'sveltekit-superforms/server';
4+
import { zod } from 'sveltekit-superforms/adapters';
5+
6+
import type {
7+
AuthForm,
8+
AuthFormCreationStrategies,
9+
AuthFormValidationStrategies,
10+
} from '$lib/types/auth_forms';
11+
12+
import { authSchema } from '$lib/zod/schema';
13+
import { SEE_OTHER } from '$lib/constants/http-response-status-codes';
14+
import { HOME_PAGE } from '$lib/constants/navbar-links';
15+
16+
/**
17+
* Initialize authentication form pages (login/signup)
18+
* Redirects to home page if already logged in,
19+
* otherwise initializes the authentication form for unauthenticated users
20+
* @param locals - The application locals containing authentication state
21+
* @returns { form: AuthForm } - The initialized authentication form
22+
*/
23+
export const initializeAuthForm = async (locals: App.Locals): Promise<{ form: AuthForm }> => {
24+
const session = await locals.auth.validate();
25+
26+
if (session) {
27+
redirect(SEE_OTHER, HOME_PAGE);
28+
}
29+
30+
return await createAuthFormWithFallback();
31+
};
32+
33+
/**
34+
* Create authentication form with comprehensive fallback handling
35+
* Tries multiple strategies until one succeeds
36+
*/
37+
export const createAuthFormWithFallback = async (): Promise<{ form: AuthForm }> => {
38+
for (const strategy of formCreationStrategies) {
39+
try {
40+
const result = await strategy.run();
41+
42+
return result;
43+
} catch (error) {
44+
if (isDevelopmentMode()) {
45+
console.warn(`Create authForm strategy: Failed to ${strategy.name}`);
46+
console.warn(error instanceof Error ? (error.stack ?? error.message) : error);
47+
}
48+
}
49+
}
50+
51+
// This should never be reached due to manual creation strategy
52+
throw new Error('Failed to create form for authentication.');
53+
};
54+
55+
/**
56+
* Form creation strategies in order of preference
57+
* Each strategy attempts a different approach to create a valid form
58+
*
59+
* See:
60+
* https://superforms.rocks/migration-v2#supervalidate
61+
* https://superforms.rocks/concepts/client-validation
62+
* https://superforms.rocks/api#supervalidate-options
63+
*/
64+
const formCreationStrategies: AuthFormCreationStrategies = [
65+
{
66+
name: '(Basic case) Use standard superValidate',
67+
async run() {
68+
const form = await superValidate(zod(authSchema));
69+
return { form: { ...form, message: '' } };
70+
},
71+
},
72+
{
73+
name: 'Create form by manually defining structure',
74+
async run() {
75+
const defaultForm = {
76+
valid: false,
77+
posted: false,
78+
errors: {},
79+
message: '',
80+
...createBaseAuthForm(),
81+
};
82+
83+
return { form: { ...defaultForm, message: '' } };
84+
},
85+
},
86+
];
87+
88+
/**
89+
* Validate authentication form data with comprehensive fallback handling
90+
* Tries multiple strategies until one succeeds
91+
*
92+
* @param request - The incoming request containing form data
93+
* @returns The validated form object (bare form, suitable for actions: fail(..., { form }))
94+
*/
95+
export const validateAuthFormWithFallback = async (request: Request): Promise<AuthForm> => {
96+
for (const strategy of formValidationStrategies) {
97+
try {
98+
const result = await strategy.run(request);
99+
100+
return result.form;
101+
} catch (error) {
102+
if (isDevelopmentMode()) {
103+
console.warn(`Validate authForm strategy: Failed to ${strategy.name}`);
104+
console.warn(error instanceof Error ? (error.stack ?? error.message) : error);
105+
}
106+
}
107+
}
108+
109+
// This should never be reached due to fallback strategy
110+
throw new Error('Failed to validate form for authentication.');
111+
};
112+
113+
/**
114+
* Form validation strategies for action handlers
115+
* Each strategy attempts a different approach to validate form data from requests
116+
*/
117+
const formValidationStrategies: AuthFormValidationStrategies = [
118+
{
119+
name: '(Basic Case) Use standard superValidate with request',
120+
async run(request: Request) {
121+
const form = await superValidate(request, zod(authSchema));
122+
return { form: { ...form, message: '' } };
123+
},
124+
},
125+
{
126+
name: 'Create fallback form manually',
127+
async run(_request: Request) {
128+
// Create a fallback form with error state
129+
// This maintains consistency with other strategies by returning { form }
130+
const fallbackForm = {
131+
valid: false,
132+
posted: true,
133+
errors: { _form: ['ログインできませんでした。'] },
134+
message: 'サーバでエラーが発生しました。本サービスの開発・運営チームまでご連絡ください。',
135+
...createBaseAuthForm(),
136+
};
137+
138+
return { form: fallbackForm };
139+
},
140+
},
141+
];
142+
143+
/**
144+
* Helper function to validate if we're in development mode
145+
* This can be mocked in tests to control logging behavior
146+
*/
147+
export const isDevelopmentMode = (): boolean => {
148+
return import.meta.env.DEV;
149+
};
150+
151+
/**
152+
* Common form structure for authentication forms
153+
* Contains constraints and shape definitions used across different form strategies
154+
*/
155+
const createBaseAuthForm = () => ({
156+
id: getBaseAuthFormId(),
157+
data: { username: '', password: '' },
158+
constraints: {
159+
username: { minlength: 3, maxlength: 24, required: true, pattern: '[\\w]*' },
160+
password: {
161+
minlength: 8,
162+
maxlength: 128,
163+
required: true,
164+
pattern: '(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\\d)[a-zA-Z\\d]{8,128}',
165+
},
166+
},
167+
shape: {
168+
username: { type: 'string' },
169+
password: { type: 'string' },
170+
},
171+
});
172+
173+
/**
174+
* Generates a unique identifier for authentication form elements.
175+
*
176+
* Uses Web Crypto API's randomUUID() when available, falling back to a
177+
* timestamp-based random string for environments where crypto is unavailable.
178+
*
179+
* @returns A unique string identifier prefixed with 'error-fallback-form-'
180+
*
181+
* @example
182+
* ```typescript
183+
* const formId = getBaseAuthFormId();
184+
* // Returns: "error-fallback-form-550e8400-e29b-41d4-a716-446655440000"
185+
* // or: "error-fallback-form-1703875200000-abc123def"
186+
* ```
187+
*/
188+
const getBaseAuthFormId = () => {
189+
return (
190+
'error-fallback-form-' +
191+
(globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`)
192+
); // Fallback when Web Crypto is unavailable
193+
};

src/lib/utils/authorship.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,75 @@ import { redirect } from '@sveltejs/kit';
33
import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes';
44
import { Roles } from '$lib/types/user';
55

6+
/**
7+
* Ensure user has a valid session or redirect to login
8+
* @param locals - The application locals containing auth and user information
9+
* @returns {Promise<void>}
10+
*/
611
export const ensureSessionOrRedirect = async (locals: App.Locals): Promise<void> => {
712
const session = await locals.auth.validate();
813

914
if (!session) {
10-
throw redirect(TEMPORARY_REDIRECT, '/login');
15+
redirect(TEMPORARY_REDIRECT, '/login');
1116
}
1217
};
1318

19+
/**
20+
* Get the current logged-in user or redirect to login
21+
* @param locals - The application locals containing auth and user information
22+
* @returns {Promise<App.Locals['user'] | null>} - The logged-in user or null
23+
*/
1424
export const getLoggedInUser = async (locals: App.Locals): Promise<App.Locals['user'] | null> => {
1525
await ensureSessionOrRedirect(locals);
1626
const loggedInUser = locals.user;
1727

1828
if (!loggedInUser) {
19-
throw redirect(TEMPORARY_REDIRECT, '/login');
29+
redirect(TEMPORARY_REDIRECT, '/login');
2030
}
2131

2232
return loggedInUser;
2333
};
2434

35+
/**
36+
* Validate if the user has admin role
37+
* @param role - User role
38+
* @returns {boolean} - True if user is admin, false otherwise
39+
*/
2540
export const isAdmin = (role: Roles): boolean => {
2641
return role === Roles.ADMIN;
2742
};
2843

44+
/**
45+
* Validate if the user has authority (is the author)
46+
* @param userId - The user id
47+
* @param authorId - The author id
48+
* @returns {boolean} - True if user has authority, false otherwise
49+
*/
2950
export const hasAuthority = (userId: string, authorId: string): boolean => {
30-
return userId.toLocaleLowerCase() === authorId.toLocaleLowerCase();
51+
return userId.toLowerCase() === authorId.toLowerCase();
3152
};
3253

33-
// Note: 公開 + 非公開(本人のみ)の問題集が閲覧できる
54+
/**
55+
* Validate if user can read the workbook
56+
* Public workbooks can be read by anyone, private workbooks only by the author
57+
* @param isPublished - Whether the workbook is published
58+
* @param userId - The user id
59+
* @param authorId - The author id
60+
* @returns {boolean} - True if user can read, false otherwise
61+
*/
3462
export const canRead = (isPublished: boolean, userId: string, authorId: string): boolean => {
3563
return isPublished || hasAuthority(userId, authorId);
3664
};
3765

38-
// Note: 特例として、管理者はユーザが公開している問題集を編集できる
66+
/**
67+
* Validate if user can edit the workbook
68+
* Authors can always edit their workbooks
69+
* Admins can edit public workbooks as a special case
70+
* @param userId - The user id
71+
* @param authorId - The author id
72+
* @param role - User role
73+
* @returns {boolean} - True if user can edit, false otherwise
74+
*/
3975
export const canEdit = (
4076
userId: string,
4177
authorId: string,
@@ -45,7 +81,13 @@ export const canEdit = (
4581
return hasAuthority(userId, authorId) || (isAdmin(role) && isPublished);
4682
};
4783

48-
// Note: 本人のみ削除可能
84+
/**
85+
* Validate if user can delete the workbook
86+
* Only the author can delete their workbooks
87+
* @param userId - The user id
88+
* @param authorId - The author id
89+
* @returns {boolean} - True if user can delete, false otherwise
90+
*/
4991
export const canDelete = (userId: string, authorId: string): boolean => {
5092
return hasAuthority(userId, authorId);
5193
};

0 commit comments

Comments
 (0)